react-x11 2.2.0 → 2.2.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.2.0",
3
+ "version": "2.2.1",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
package/src/Reconciler.js CHANGED
@@ -34,7 +34,11 @@ import {
34
34
  } from './nodes.js';
35
35
  import { hasDropProps } from './dnd.js';
36
36
  import { AppProvider } from './appcontext.js';
37
- import { defaultRootHandlers, setErrorHandler } from './errors.js';
37
+ import {
38
+ defaultRootHandlers,
39
+ setErrorHandler,
40
+ STRICT_TOKENS,
41
+ } from './errors.js';
38
42
  import {
39
43
  registerApp,
40
44
  unregisterApp,
@@ -303,15 +307,29 @@ const HostConfig = {
303
307
  // and trapFocus need commitMount too — the node has to be in the tree
304
308
  // first, so it can find the EventManager that owns focus. Drop targets
305
309
  // likewise: registration needs the root, which insertion assigns.
310
+ //
311
+ // Under REACT_X11_STRICT_TOKENS every token-styled node asks for one as
312
+ // well, since a bad token is only *found* once the node is attached —
313
+ // which is after this ran — and commitMount is the first moment React
314
+ // holds that node's own fiber (nodes.js `_tokenProblem`). Gated on the
315
+ // flag so the default mount pays nothing for a debugging mode.
306
316
  return (
307
317
  type === 'popup' ||
308
318
  Boolean(props.autoFocus) ||
309
319
  Boolean(props.trapFocus) ||
310
- hasDropProps(props)
320
+ hasDropProps(props) ||
321
+ (STRICT_TOKENS && instance._usesTokens)
311
322
  );
312
323
  },
313
324
 
314
325
  commitMount(instance, type, props) {
326
+ // first, and before any of the work below: the tree is on its way out.
327
+ // `false` afterwards marks this instance's one commitMount spent, so a
328
+ // later re-attach throws at once rather than deferring to a call that
329
+ // will never come (nodes.js `_tokenProblem`).
330
+ const tokenError = instance._tokenError;
331
+ instance._tokenError = false;
332
+ if (tokenError) throw tokenError;
315
333
  if (type === 'popup') {
316
334
  instance.realize(null);
317
335
  }
package/src/errors.js CHANGED
@@ -59,6 +59,48 @@ export function reportHandlerError(node, handler, error) {
59
59
  markFailed();
60
60
  }
61
61
 
62
+ /**
63
+ * `REACT_X11_STRICT_TOKENS=1` makes a `$token` the theme does not define
64
+ * fatal again, for a build that would rather stop than paint something
65
+ * wrong. The default reports and carries on — see `reportStyleError`.
66
+ *
67
+ * Guarded rather than a bare `process.env` because the playground bundle
68
+ * runs in a browser, where there is no `process` at all.
69
+ */
70
+ export const STRICT_TOKENS =
71
+ (typeof process === 'undefined'
72
+ ? undefined
73
+ : process.env?.REACT_X11_STRICT_TOKENS) === '1';
74
+
75
+ /** Messages already printed, so a shared misspelled style reports once per
76
+ * (node, message) rather than once per restyle — a theme swap re-resolves
77
+ * the whole subtree and would otherwise print the same line every time. */
78
+ const reportedStyleErrors = new WeakMap();
79
+
80
+ /**
81
+ * A style the node cannot resolve — today only an unknown `$token`.
82
+ *
83
+ * Not a throw, and deliberately: the mistake is one property in one style,
84
+ * and the tree it would take down is the whole GUI. The property is dropped
85
+ * (so the widget paints without it, visibly wrong), the message names the
86
+ * token and whose element wore it, and `process.exitCode` is set so a test
87
+ * run or a supervisor still counts this as a failure. `REACT_X11_STRICT_TOKENS=1`
88
+ * restores the throw.
89
+ */
90
+ export function reportStyleError(node, message) {
91
+ const seen = reportedStyleErrors.get(node);
92
+ if (seen?.has(message)) return;
93
+ if (seen) seen.add(message);
94
+ else reportedStyleErrors.set(node, new Set([message]));
95
+ const owner = ownerName(node);
96
+ console.error(
97
+ `${message}${owner ? ` — in ${owner}` : ''}. ` +
98
+ 'The property is dropped and the app carries on; set ' +
99
+ 'REACT_X11_STRICT_TOKENS=1 to make this throw instead.',
100
+ );
101
+ markFailed();
102
+ }
103
+
62
104
  /** Wrap a call to user code so a throw is reported instead of escaping. */
63
105
  export function callHandler(node, handler, fn, ev) {
64
106
  try {
package/src/nodes.js CHANGED
@@ -109,7 +109,12 @@ import {
109
109
  windowOrigin,
110
110
  } from './anchor.js';
111
111
  import { baseTheme } from './palette.js';
112
- import { callHandler, ownerName } from './errors.js';
112
+ import {
113
+ callHandler,
114
+ ownerName,
115
+ reportStyleError,
116
+ STRICT_TOKENS,
117
+ } from './errors.js';
113
118
  import {
114
119
  hooks as a11yHooks,
115
120
  isFocusable as a11yFocusable,
@@ -1557,6 +1562,11 @@ export class Node {
1557
1562
  // subtree's hit reach, invalidated through _clearHitBounds()
1558
1563
  this._paintOrderCache = null;
1559
1564
  this._hitBoundsCache = null;
1565
+ // a `$token` the theme does not define, held for `commitMount` to throw
1566
+ // on this node's own fiber — see `_tokenProblem`. Strict mode only.
1567
+ // `null` is "commitMount is still to come", `false` is "it has been and
1568
+ // gone", and an Error is one waiting for it
1569
+ this._tokenError = null;
1560
1570
  this._syncStyle(props);
1561
1571
  this.yoga = yoga ? createLayoutNode() : null;
1562
1572
  if (this.yoga) {
@@ -1588,7 +1598,7 @@ export class Node {
1588
1598
  * them. `baseStyle` is the flattened `style` prop; `style` is that with
1589
1599
  * the active state blocks overlaid.
1590
1600
  */
1591
- _syncStyle(props) {
1601
+ _syncStyle(props, mounting = false) {
1592
1602
  if (DEV && this.stylable) {
1593
1603
  assertNoFlatStyleProps(props, this.kind, this.semanticNames);
1594
1604
  validateStyle(flattenStyle(props.style), `<${this.kind} style>`);
@@ -1597,12 +1607,16 @@ export class Node {
1597
1607
  this._usesTokens = this.stylable && styleUsesTokens(this._baseStyle);
1598
1608
  if (this._usesTokens) {
1599
1609
  const theme = this.theme;
1610
+ const strict = this.placed;
1611
+ const problems = strict ? [] : null;
1600
1612
  this._baseStyle = resolveTokens(
1601
1613
  this._baseStyle,
1602
1614
  theme,
1603
1615
  `<${this.kind} style>`,
1604
- this.placed,
1616
+ strict,
1617
+ problems,
1605
1618
  );
1619
+ if (problems?.length) this._tokenProblem(problems, mounting);
1606
1620
  }
1607
1621
  // `disabled` is a prop, not something the pointer does, so it is read
1608
1622
  // straight off props rather than driven by the event manager
@@ -2241,6 +2255,46 @@ export class Node {
2241
2255
  return owner.isPopup ? owner.parent != null : true;
2242
2256
  }
2243
2257
 
2258
+ /**
2259
+ * A `$token` this node's completed ancestry does not define.
2260
+ *
2261
+ * The default is `reportStyleError`: say so loudly, set `process.exitCode`,
2262
+ * and keep the property dropped. `REACT_X11_STRICT_TOKENS=1` makes it fatal
2263
+ * again, and then *where* the throw lands is the whole question — an error
2264
+ * boundary only catches what React invoked, on the fiber React thinks it
2265
+ * is working on.
2266
+ *
2267
+ * `mounting` is the attach walk, which runs inside `appendInitialChild`
2268
+ * while React is completing the nearest host *ancestor* — the `<window>`,
2269
+ * for a whole tree rendered at once. A throw there is attributed to the
2270
+ * window and sails past every boundary the app wrote inside it, which is
2271
+ * the bug this deferral exists for (#420). Stashed instead, and thrown
2272
+ * from `commitMount` on this node's own fiber, where the walk up finds a
2273
+ * boundary at any depth.
2274
+ *
2275
+ * Every other caller already has the right fiber (`commitUpdate`) or has
2276
+ * no React on the stack at all (`appearanceChanged`, from an X event) —
2277
+ * for those, throwing here is both the earliest and the only option, and
2278
+ * the second is the crash strict mode asked for.
2279
+ *
2280
+ * `commitMount` happens once per instance, so a node re-attached after it
2281
+ * has been and gone has nothing left to defer *to*; stashing there would
2282
+ * swallow the error instead of raising it late. Those throw at once, like
2283
+ * the keyed reorder they resemble.
2284
+ */
2285
+ _tokenProblem(problems, mounting) {
2286
+ if (!STRICT_TOKENS) {
2287
+ // every one of them: two misspellings in a style are two things to
2288
+ // fix, and a report that named only the first would send someone back
2289
+ // for a second run to find the second
2290
+ for (const message of problems) reportStyleError(this, message);
2291
+ return;
2292
+ }
2293
+ const error = new Error(problems[0]);
2294
+ if (mounting && this._tokenError === null) this._tokenError = error;
2295
+ else throw error;
2296
+ }
2297
+
2244
2298
  /** The owning window resized: re-resolve, since a query block may now
2245
2299
  * match that did not, or the other way round. */
2246
2300
  _sizeQueriesChanged() {
@@ -2287,7 +2341,7 @@ export class Node {
2287
2341
  if (this.isWindow) this._syncWindowBackground();
2288
2342
  if (this._usesTokens) {
2289
2343
  const before = this.style;
2290
- this._syncStyle(this.props);
2344
+ this._syncStyle(this.props, mounting);
2291
2345
  // a token change reaches the node without React re-rendering it, so
2292
2346
  // the invalidation a commit would have done has to happen here too
2293
2347
  if (localTextStyleChanged(this.style, before)) {
package/src/style.d.ts CHANGED
@@ -43,12 +43,15 @@ export function tokenNames(
43
43
  style: StyleProperties,
44
44
  out?: Set<string>,
45
45
  ): Set<string>;
46
- /** Replace `$token` references with values from the theme. */
46
+ /** Replace `$token` references with values from the theme. A token the theme
47
+ * does not define is dropped either way; with `strict`, the message naming
48
+ * it is pushed onto `problems` for the caller to report or throw. */
47
49
  export function resolveTokens(
48
50
  style: StyleProperties,
49
51
  theme: Record<string, unknown> | null | undefined,
50
52
  where?: string,
51
53
  strict?: boolean,
54
+ problems?: string[] | null,
52
55
  ): StyleProperties;
53
56
 
54
57
  export function styleHasSizeQueries(style: StyleProperties): boolean;
package/src/styles.js CHANGED
@@ -1206,16 +1206,37 @@ export function stripTokens(style) {
1206
1206
  * `strict` says the node's ancestry is complete, so a token that does not
1207
1207
  * resolve is a mistake. While a subtree is still being built its nodes can
1208
1208
  * see only part of their ancestry — the theme two levels up does not exist
1209
- * for them yet — so resolution there is provisional: unknown tokens are
1210
- * dropped and the node restyles when it attaches.
1209
+ * for them yet — so resolution there is provisional.
1210
+ *
1211
+ * Both cases drop the property, because a value is either resolved or absent
1212
+ * and `'$textMuted1'` is not a colour. What `strict` changes is whether
1213
+ * anyone hears about it: mistakes are pushed onto `problems` and the caller
1214
+ * decides what one costs. Resolving itself never throws — it runs from a
1215
+ * commit and from an X event alike, and only the caller knows whether React
1216
+ * is on the stack to route a throw to a boundary (src/nodes.js).
1217
+ *
1218
+ * A cache hit replays the problems it recorded, so the second node to wear a
1219
+ * misspelled shared style is reported like the first.
1211
1220
  */
1212
- export function resolveTokens(style, theme, where = 'style', strict = true) {
1221
+ export function resolveTokens(
1222
+ style,
1223
+ theme,
1224
+ where = 'style',
1225
+ strict = true,
1226
+ problems = null,
1227
+ ) {
1213
1228
  if (!theme) return stripTokens(style);
1214
1229
  let byTheme = strict ? resolvedCache.get(style) : null;
1215
1230
  if (strict && !byTheme) resolvedCache.set(style, (byTheme = new WeakMap()));
1216
1231
  const hit = byTheme?.get(theme);
1217
- if (hit) return hit;
1232
+ if (hit) {
1233
+ if (problems && hit.problems) problems.push(...hit.problems);
1234
+ return hit.out;
1235
+ }
1218
1236
 
1237
+ // collected here rather than pushed straight to `problems` so the cache
1238
+ // entry can keep them: the caller that misses is not the only one to hear
1239
+ const found = strict ? [] : null;
1219
1240
  const out = {};
1220
1241
  for (const key of Object.keys(style)) {
1221
1242
  const v = style[key];
@@ -1225,11 +1246,7 @@ export function resolveTokens(style, theme, where = 'style', strict = true) {
1225
1246
  out[key] = theme[name];
1226
1247
  continue;
1227
1248
  }
1228
- if (!strict) continue;
1229
- throw new Error(
1230
- `react-x11: unknown theme token "${v}" in ${where} ` +
1231
- `(theme has ${Object.keys(theme).join(', ') || 'nothing'})`,
1232
- );
1249
+ if (strict) found.push(unknownToken(v, theme, where));
1233
1250
  } else if (mentionsToken(v)) {
1234
1251
  let unknown = null;
1235
1252
  const substituted = v.replace(TOKEN_IN_VALUE, (token) => {
@@ -1245,13 +1262,10 @@ export function resolveTokens(style, theme, where = 'style', strict = true) {
1245
1262
  // frame instead of at the style.
1246
1263
  if (!unknown) out[key] = substituted;
1247
1264
  else if (strict) {
1248
- throw new Error(
1249
- `react-x11: unknown theme token "${unknown}" in ${where} ${key} ` +
1250
- `(theme has ${Object.keys(theme).join(', ') || 'nothing'})`,
1251
- );
1265
+ found.push(unknownToken(unknown, theme, `${where} ${key}`));
1252
1266
  }
1253
1267
  } else if (key.charCodeAt(0) === 58 && v) {
1254
- out[key] = resolveTokens(v, theme, `${where} ${key}`, strict);
1268
+ out[key] = resolveTokens(v, theme, `${where} ${key}`, strict, found);
1255
1269
  } else if (key === 'animation' && v && typeof v === 'object') {
1256
1270
  const loops = {};
1257
1271
  let incomplete = false;
@@ -1266,11 +1280,11 @@ export function resolveTokens(style, theme, where = 'style', strict = true) {
1266
1280
  theme,
1267
1281
  `${where} animation ${prop}`,
1268
1282
  strict,
1283
+ found,
1269
1284
  );
1270
- // A provisional resolution drops what it cannot resolve, which for
1271
- // an ordinary property means "not styled yet". A loop with one end
1272
- // missing is not a shorter loop, so the whole declaration waits for
1273
- // the ancestry to complete rather than throwing at a half of one.
1285
+ // A loop with one end missing is not a shorter loop, so a
1286
+ // declaration that lost a value is dropped whole rather than run
1287
+ // between a colour and nothing.
1274
1288
  if (Object.keys(resolved).length !== Object.keys(entry).length) {
1275
1289
  incomplete = true;
1276
1290
  }
@@ -1281,10 +1295,20 @@ export function resolveTokens(style, theme, where = 'style', strict = true) {
1281
1295
  out[key] = v;
1282
1296
  }
1283
1297
  }
1284
- byTheme?.set(theme, out);
1298
+ if (found?.length && problems) problems.push(...found);
1299
+ byTheme?.set(theme, { out, problems: found?.length ? found : null });
1285
1300
  return out;
1286
1301
  }
1287
1302
 
1303
+ /** The one message, written once: what was named, and what the palette in
1304
+ * force actually has — listing the alternatives is most of the fix. */
1305
+ function unknownToken(token, theme, where) {
1306
+ return (
1307
+ `react-x11: unknown theme token "${token}" in ${where} ` +
1308
+ `(theme has ${Object.keys(theme).join(', ') || 'nothing'})`
1309
+ );
1310
+ }
1311
+
1288
1312
  export { validateStyle };
1289
1313
 
1290
1314
  /**