@astryxdesign/core 0.4.0 → 0.4.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.
Files changed (194) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/AspectRatio/AspectRatio.d.ts.map +1 -1
  3. package/dist/AspectRatio/AspectRatio.js +1 -2
  4. package/dist/AspectRatio/index.d.ts.map +1 -1
  5. package/dist/AspectRatio/index.js +1 -2
  6. package/dist/Badge/Badge.d.ts.map +1 -1
  7. package/dist/Badge/Badge.js +1 -2
  8. package/dist/Badge/index.d.ts.map +1 -1
  9. package/dist/Badge/index.js +1 -2
  10. package/dist/Banner/Banner.d.ts +3 -0
  11. package/dist/Banner/Banner.d.ts.map +1 -1
  12. package/dist/Banner/Banner.js +17 -3
  13. package/dist/Blockquote/Blockquote.d.ts.map +1 -1
  14. package/dist/Blockquote/Blockquote.js +1 -2
  15. package/dist/Blockquote/index.d.ts.map +1 -1
  16. package/dist/Blockquote/index.js +1 -2
  17. package/dist/Button/Button.d.ts.map +1 -1
  18. package/dist/Button/Button.js +2 -2
  19. package/dist/Card/Card.d.ts.map +1 -1
  20. package/dist/Card/Card.js +1 -2
  21. package/dist/Card/index.d.ts.map +1 -1
  22. package/dist/Card/index.js +1 -2
  23. package/dist/Center/Center.d.ts.map +1 -1
  24. package/dist/Center/Center.js +1 -2
  25. package/dist/Center/index.d.ts.map +1 -1
  26. package/dist/Center/index.js +1 -2
  27. package/dist/Code/Code.d.ts.map +1 -1
  28. package/dist/Code/Code.js +1 -2
  29. package/dist/CommandPalette/CommandPalette.d.ts.map +1 -1
  30. package/dist/CommandPalette/CommandPalette.js +5 -0
  31. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  32. package/dist/ComplexSelector/ComplexSelector.js +7 -8
  33. package/dist/DateInput/DateInput.d.ts.map +1 -1
  34. package/dist/DateInput/DateInput.js +23 -10
  35. package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
  36. package/dist/DateRangeInput/DateRangeInput.js +44 -16
  37. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  38. package/dist/DateTimeInput/DateTimeInput.js +32 -13
  39. package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts +1 -1
  40. package/dist/DropdownMenu/DropdownMenuRadioGroup.d.ts.map +1 -1
  41. package/dist/DropdownMenu/DropdownMenuRadioGroup.js +10 -3
  42. package/dist/Field/PanelSearchInput.d.ts +81 -0
  43. package/dist/Field/PanelSearchInput.d.ts.map +1 -0
  44. package/dist/Field/PanelSearchInput.js +151 -0
  45. package/dist/FileInput/FileInput.d.ts.map +1 -1
  46. package/dist/FileInput/FileInput.js +21 -10
  47. package/dist/Grid/Grid.d.ts.map +1 -1
  48. package/dist/Grid/Grid.js +1 -2
  49. package/dist/Grid/index.d.ts.map +1 -1
  50. package/dist/Grid/index.js +1 -2
  51. package/dist/Icon/globalIconRegistry.d.ts +5 -4
  52. package/dist/Icon/globalIconRegistry.d.ts.map +1 -1
  53. package/dist/Icon/globalIconRegistry.js +5 -4
  54. package/dist/Indicator/index.d.ts +1 -1
  55. package/dist/Indicator/index.d.ts.map +1 -1
  56. package/dist/Indicator/types.d.ts +10 -0
  57. package/dist/Indicator/types.d.ts.map +1 -1
  58. package/dist/Lightbox/Lightbox.d.ts.map +1 -1
  59. package/dist/Lightbox/Lightbox.js +24 -50
  60. package/dist/MultiSelector/MultiSelector.d.ts +8 -1
  61. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  62. package/dist/MultiSelector/MultiSelector.js +134 -102
  63. package/dist/Popover/usePopover.d.ts +15 -0
  64. package/dist/Popover/usePopover.d.ts.map +1 -1
  65. package/dist/Popover/usePopover.js +14 -2
  66. package/dist/ProgressBar/ProgressBar.d.ts.map +1 -1
  67. package/dist/ProgressBar/ProgressBar.js +2 -3
  68. package/dist/Section/Section.d.ts.map +1 -1
  69. package/dist/Section/Section.js +1 -2
  70. package/dist/Section/index.d.ts.map +1 -1
  71. package/dist/Section/index.js +1 -2
  72. package/dist/Selector/Selector.d.ts +10 -0
  73. package/dist/Selector/Selector.d.ts.map +1 -1
  74. package/dist/Selector/Selector.js +135 -95
  75. package/dist/Skeleton/Skeleton.d.ts.map +1 -1
  76. package/dist/Skeleton/Skeleton.js +1 -2
  77. package/dist/Skeleton/index.d.ts.map +1 -1
  78. package/dist/Skeleton/index.js +1 -2
  79. package/dist/Slider/Slider.d.ts.map +1 -1
  80. package/dist/Slider/Slider.js +2 -6
  81. package/dist/Switch/Switch.d.ts.map +1 -1
  82. package/dist/Switch/Switch.js +3 -3
  83. package/dist/Table/TableRow.d.ts +1 -1
  84. package/dist/Table/TableRow.d.ts.map +1 -1
  85. package/dist/Table/TableRow.js +4 -2
  86. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  87. package/dist/TimeInput/TimeInput.js +7 -2
  88. package/dist/Timestamp/Timestamp.d.ts.map +1 -1
  89. package/dist/Timestamp/Timestamp.js +14 -3
  90. package/dist/Timestamp/formatInstant.d.ts +11 -1
  91. package/dist/Timestamp/formatInstant.d.ts.map +1 -1
  92. package/dist/Timestamp/formatInstant.js +3 -1
  93. package/dist/VisuallyHidden/VisuallyHidden.d.ts.map +1 -1
  94. package/dist/VisuallyHidden/VisuallyHidden.js +1 -2
  95. package/dist/VisuallyHidden/index.d.ts.map +1 -1
  96. package/dist/VisuallyHidden/index.js +1 -2
  97. package/dist/astryx.css +18 -17
  98. package/dist/astryx.umd.js +45 -45
  99. package/dist/astryx.umd.js.map +4 -4
  100. package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
  101. package/dist/hooks/useInputStatusIcon.js +17 -4
  102. package/dist/theme/defineTheme.d.ts +2 -2
  103. package/dist/theme/defineTheme.d.ts.map +1 -1
  104. package/dist/theme/defineTheme.js +2 -1
  105. package/dist/theme/index.d.ts +1 -1
  106. package/dist/theme/index.d.ts.map +1 -1
  107. package/dist/theme/index.js +1 -1
  108. package/dist/theme/tokens.stylex.d.ts +12 -0
  109. package/dist/theme/tokens.stylex.d.ts.map +1 -1
  110. package/dist/theme/tokens.stylex.js +23 -0
  111. package/dist/utils/focusOutline.stylex.d.ts +34 -19
  112. package/dist/utils/focusOutline.stylex.d.ts.map +1 -1
  113. package/dist/utils/focusOutline.stylex.js +46 -33
  114. package/dist/utils/index.d.ts +1 -0
  115. package/dist/utils/index.d.ts.map +1 -1
  116. package/dist/utils/index.js +5 -1
  117. package/dist/utils/interactionModality.d.ts +34 -0
  118. package/dist/utils/interactionModality.d.ts.map +1 -0
  119. package/dist/utils/interactionModality.js +70 -0
  120. package/locales/en.json +40 -0
  121. package/locales/pseudo.json +30 -0
  122. package/package.json +2 -2
  123. package/src/AppShell/AppShell.doc.mjs +3 -0
  124. package/src/AspectRatio/AspectRatio.tsx +0 -2
  125. package/src/AspectRatio/index.ts +0 -2
  126. package/src/Badge/Badge.tsx +0 -2
  127. package/src/Badge/index.ts +0 -2
  128. package/src/Banner/Banner.test.tsx +58 -1
  129. package/src/Banner/Banner.tsx +24 -5
  130. package/src/Blockquote/Blockquote.tsx +0 -2
  131. package/src/Blockquote/index.ts +0 -2
  132. package/src/Button/Button.doc.mjs +2 -2
  133. package/src/Button/Button.tsx +5 -4
  134. package/src/Card/Card.tsx +0 -2
  135. package/src/Card/index.ts +0 -2
  136. package/src/Center/Center.tsx +0 -2
  137. package/src/Center/index.ts +0 -2
  138. package/src/CheckboxInput/CheckboxInput.test.tsx +8 -3
  139. package/src/Code/Code.tsx +0 -2
  140. package/src/CommandPalette/CommandPalette.test.tsx +54 -0
  141. package/src/CommandPalette/CommandPalette.tsx +5 -0
  142. package/src/ComplexSelector/ComplexSelector.test.tsx +15 -6
  143. package/src/ComplexSelector/ComplexSelector.tsx +6 -10
  144. package/src/DateInput/DateInput.test.tsx +19 -0
  145. package/src/DateInput/DateInput.tsx +3 -6
  146. package/src/DateRangeInput/DateRangeInput.tsx +3 -9
  147. package/src/DateTimeInput/DateTimeInput.test.tsx +79 -1
  148. package/src/DateTimeInput/DateTimeInput.tsx +23 -9
  149. package/src/DropdownMenu/DropdownMenuRadioGroup.tsx +2 -1
  150. package/src/Field/PanelSearchInput.tsx +271 -0
  151. package/src/FileInput/FileInput.tsx +21 -8
  152. package/src/Grid/Grid.tsx +0 -2
  153. package/src/Grid/index.ts +0 -2
  154. package/src/Icon/globalIconRegistry.tsx +5 -4
  155. package/src/Indicator/Indicator.doc.mjs +1 -1
  156. package/src/Indicator/index.ts +1 -0
  157. package/src/Indicator/types.ts +11 -0
  158. package/src/Lightbox/Lightbox.tsx +2 -11
  159. package/src/MultiSelector/MultiSelector.doc.mjs +9 -0
  160. package/src/MultiSelector/MultiSelector.test.tsx +135 -5
  161. package/src/MultiSelector/MultiSelector.tsx +170 -127
  162. package/src/Popover/Popover.doc.mjs +2 -0
  163. package/src/Popover/usePopover.tsx +37 -4
  164. package/src/ProgressBar/ProgressBar.tsx +2 -8
  165. package/src/Section/Section.tsx +0 -2
  166. package/src/Section/index.ts +0 -2
  167. package/src/Selector/Selector.doc.mjs +10 -0
  168. package/src/Selector/Selector.test.tsx +386 -2
  169. package/src/Selector/Selector.tsx +182 -122
  170. package/src/Skeleton/Skeleton.tsx +0 -2
  171. package/src/Skeleton/index.ts +0 -2
  172. package/src/Slider/Slider.tsx +2 -11
  173. package/src/Switch/Switch.tsx +10 -2
  174. package/src/Table/Table.test.tsx +31 -0
  175. package/src/Table/TableRow.tsx +6 -0
  176. package/src/TimeInput/TimeInput.test.tsx +54 -0
  177. package/src/TimeInput/TimeInput.tsx +17 -2
  178. package/src/Timestamp/Timestamp.test.tsx +91 -14
  179. package/src/Timestamp/Timestamp.tsx +14 -2
  180. package/src/Timestamp/formatInstant.ts +16 -1
  181. package/src/TopNav/TopNavMegaMenu.doc.mjs +5 -0
  182. package/src/TreeList/TreeList.test.tsx +3 -2
  183. package/src/VisuallyHidden/VisuallyHidden.tsx +0 -2
  184. package/src/VisuallyHidden/index.ts +0 -2
  185. package/src/hooks/useInputStatusIcon.tsx +8 -8
  186. package/src/serverSafeComponents.test.ts +665 -0
  187. package/src/theme/defineTheme.ts +3 -0
  188. package/src/theme/generateThemeRules.test.ts +17 -0
  189. package/src/theme/index.ts +2 -0
  190. package/src/theme/tokens.stylex.ts +18 -0
  191. package/src/theme/tokens.test.ts +37 -0
  192. package/src/utils/focusOutline.stylex.ts +37 -23
  193. package/src/utils/index.ts +4 -0
  194. package/src/utils/interactionModality.ts +74 -0
@@ -0,0 +1,665 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Guards the RSC server/client boundary of every public component (#823).
5
+ * @input Reads packages/core/package.json export map + every source file under
6
+ * packages/core/src, parsed with the TypeScript compiler API
7
+ * @output Unit tests for the detection rules, plus two invariants tying the
8
+ * presence of `'use client'` to what a component's import graph actually needs
9
+ * @position Cross-cutting meta-test; sibling of scripts/check-use-client.mjs
10
+ *
11
+ * `scripts/check-use-client.mjs` enforces one direction only, and only
12
+ * directly: a file that *imports* a React client API must carry the directive.
13
+ * It is blind to two things this test covers:
14
+ *
15
+ * 1. Components carrying `'use client'` that no longer need it. A stale
16
+ * directive is not a lint error, but it pins the component to the client
17
+ * bundle and blocks it from ever resolving through a `react-server`
18
+ * export condition (#823 Phase 2).
19
+ * 2. Components that become client-only *transitively* — e.g. a directive-
20
+ * free `Badge.tsx` starting to import `Tooltip`. Badge itself imports no
21
+ * React client API, so check-use-client.mjs stays silent, yet Badge is no
22
+ * longer server-renderable.
23
+ *
24
+ * Both invariants below derive the server-safe set from the import graph, so
25
+ * the safe list is never written down twice — adding a hook (or a client
26
+ * import) to one of these components fails the test on its own.
27
+ *
28
+ * SYNC: When modified, update this header and scripts/check-use-client.mjs if
29
+ * the client-API list changes.
30
+ */
31
+
32
+ import {describe, it, expect} from 'vitest';
33
+ import {readdirSync, readFileSync, existsSync, statSync} from 'node:fs';
34
+ import {createRequire} from 'node:module';
35
+ import {join, dirname, resolve, relative} from 'node:path';
36
+ import ts from 'typescript';
37
+
38
+ const SRC_DIR = __dirname;
39
+ const PKG_JSON = join(SRC_DIR, '..', 'package.json');
40
+
41
+ /**
42
+ * React APIs that only exist in a client component. Kept in sync with
43
+ * `CLIENT_APIS` in scripts/check-use-client.mjs.
44
+ */
45
+ const CLIENT_APIS = new Set([
46
+ 'createContext',
47
+ 'useContext',
48
+ // React 19's context read. `use(promise)` *is* legal on the server, so this
49
+ // over-approximates — but every `use()` call site in this package reads a
50
+ // context, which is exactly as client-only as useContext.
51
+ 'use',
52
+ 'useActionState',
53
+ 'useState',
54
+ 'useEffect',
55
+ 'useEffectEvent',
56
+ 'useRef',
57
+ 'useCallback',
58
+ 'useMemo',
59
+ 'useReducer',
60
+ 'useId',
61
+ 'useTransition',
62
+ 'useOptimistic',
63
+ 'useSyncExternalStore',
64
+ 'useLayoutEffect',
65
+ 'useInsertionEffect',
66
+ 'useImperativeHandle',
67
+ 'useDeferredValue',
68
+ // The one non-hook export in react's client-only set.
69
+ 'startTransition',
70
+ ]);
71
+
72
+ const EXTENSIONS = ['.tsx', '.ts', '.mjs', '.js', '.jsx'];
73
+
74
+ const isSourceFile = (name: string) =>
75
+ /\.[jt]sx?$/.test(name) &&
76
+ !/\.(test|test-violations|stories|doc|perf)\./.test(name) &&
77
+ !name.endsWith('.d.ts');
78
+
79
+ /** Resolve a relative import specifier the way a bundler would. */
80
+ function resolveSpecifier(spec: string, fromFile: string): string | null {
81
+ if (!spec.startsWith('.')) {
82
+ return null;
83
+ }
84
+ const base = resolve(dirname(fromFile), spec);
85
+ for (const ext of EXTENSIONS) {
86
+ if (existsSync(base + ext)) {
87
+ return base + ext;
88
+ }
89
+ }
90
+ if (existsSync(base) && statSync(base).isDirectory()) {
91
+ for (const ext of EXTENSIONS) {
92
+ const index = join(base, `index${ext}`);
93
+ if (existsSync(index)) {
94
+ return index;
95
+ }
96
+ }
97
+ }
98
+ return null;
99
+ }
100
+
101
+ interface ReExport {
102
+ target: string;
103
+ exportedName: string;
104
+ localName: string;
105
+ }
106
+
107
+ interface ModuleInfo {
108
+ rel: string;
109
+ hasUseClient: boolean;
110
+ clientAPIs: string[];
111
+ reactDom: string[];
112
+ moduleMutableState: string[];
113
+ /** Plain imports: the names pulled, or '*' for default/namespace/side-effect. */
114
+ imports: {target: string; names: Set<string> | '*'}[];
115
+ reExports: ReExport[];
116
+ starReExports: string[];
117
+ /** True when every top-level statement is an import or a re-export. */
118
+ isPureBarrel: boolean;
119
+ }
120
+
121
+ const moduleCache = new Map<string, ModuleInfo>();
122
+
123
+ /** Every named binding in the clause is `type`-only, so the import is erased. */
124
+ function isTypeOnlyImport(clause: ts.ImportClause): boolean {
125
+ if (clause.isTypeOnly) {
126
+ return true;
127
+ }
128
+ if (clause.name) {
129
+ return false;
130
+ }
131
+ const bindings = clause.namedBindings;
132
+ if (!bindings || !ts.isNamedImports(bindings)) {
133
+ return false;
134
+ }
135
+ return (
136
+ bindings.elements.length > 0 &&
137
+ bindings.elements.every(element => element.isTypeOnly)
138
+ );
139
+ }
140
+
141
+ function isTypeOnlyExport(node: ts.ExportDeclaration): boolean {
142
+ if (node.isTypeOnly) {
143
+ return true;
144
+ }
145
+ const clause = node.exportClause;
146
+ if (!clause || !ts.isNamedExports(clause)) {
147
+ return false;
148
+ }
149
+ return (
150
+ clause.elements.length > 0 &&
151
+ clause.elements.every(element => element.isTypeOnly)
152
+ );
153
+ }
154
+
155
+ function parseModule(file: string): ModuleInfo {
156
+ const cached = moduleCache.get(file);
157
+ if (cached) {
158
+ return cached;
159
+ }
160
+ const info = parseSource(readFileSync(file, 'utf-8'), file);
161
+ moduleCache.set(file, info);
162
+ return info;
163
+ }
164
+
165
+ /**
166
+ * Parse one module's source. Split from {@link parseModule} so the detection
167
+ * rules can be unit-tested against source strings rather than real files.
168
+ */
169
+ function parseSource(text: string, file: string): ModuleInfo {
170
+ const sourceFile = ts.createSourceFile(
171
+ file,
172
+ text,
173
+ ts.ScriptTarget.ESNext,
174
+ true,
175
+ ts.ScriptKind.TSX,
176
+ );
177
+
178
+ const info: ModuleInfo = {
179
+ rel: relative(SRC_DIR, file),
180
+ hasUseClient: false,
181
+ clientAPIs: [],
182
+ reactDom: [],
183
+ moduleMutableState: [],
184
+ imports: [],
185
+ reExports: [],
186
+ starReExports: [],
187
+ isPureBarrel: true,
188
+ };
189
+
190
+ // Directive prologue: only comments and blank lines may precede it, which
191
+ // the parser has already stripped by the time we see `statements`. Walk past
192
+ // *every* string-literal directive — stopping at the first non-`use client`
193
+ // one would let `'use strict';` hide the directive behind it.
194
+ for (const statement of sourceFile.statements) {
195
+ if (
196
+ ts.isExpressionStatement(statement) &&
197
+ ts.isStringLiteral(statement.expression)
198
+ ) {
199
+ if (statement.expression.text === 'use client') {
200
+ info.hasUseClient = true;
201
+ }
202
+ continue;
203
+ }
204
+ break;
205
+ }
206
+
207
+ /** Local names bound to the react namespace: `import React from 'react'`. */
208
+ const reactNamespaces = new Set<string>();
209
+
210
+ for (const statement of sourceFile.statements) {
211
+ if (ts.isImportDeclaration(statement)) {
212
+ const spec = (statement.moduleSpecifier as ts.StringLiteral).text;
213
+ const clause = statement.importClause;
214
+ const typeOnly = clause ? isTypeOnlyImport(clause) : false;
215
+
216
+ if (spec === 'react' && clause && !clause.isTypeOnly) {
217
+ const reactBindings = clause.namedBindings;
218
+ if (reactBindings && ts.isNamedImports(reactBindings)) {
219
+ for (const element of reactBindings.elements) {
220
+ const imported = (element.propertyName ?? element.name).text;
221
+ if (!element.isTypeOnly && CLIENT_APIS.has(imported)) {
222
+ info.clientAPIs.push(imported);
223
+ }
224
+ }
225
+ }
226
+ // `import React from 'react'` and `import * as React from 'react'` put
227
+ // every client API one property access away, where the named scan above
228
+ // cannot see it. Record the local name; the sweep below finds the uses.
229
+ if (clause.name) {
230
+ reactNamespaces.add(clause.name.text);
231
+ }
232
+ if (reactBindings && ts.isNamespaceImport(reactBindings)) {
233
+ reactNamespaces.add(reactBindings.name.text);
234
+ }
235
+ }
236
+ if (!typeOnly && spec.startsWith('react-dom')) {
237
+ info.reactDom.push(spec);
238
+ }
239
+
240
+ if (!typeOnly) {
241
+ const target = resolveSpecifier(spec, file);
242
+ if (target) {
243
+ let names: Set<string> | '*' = '*';
244
+ const bindings = clause?.namedBindings;
245
+ if (bindings && ts.isNamedImports(bindings) && !clause?.name) {
246
+ names = new Set(
247
+ bindings.elements
248
+ .filter(element => !element.isTypeOnly)
249
+ .map(element => (element.propertyName ?? element.name).text),
250
+ );
251
+ }
252
+ info.imports.push({target, names});
253
+ }
254
+ }
255
+ continue;
256
+ }
257
+
258
+ if (ts.isExportDeclaration(statement) && statement.moduleSpecifier) {
259
+ const spec = (statement.moduleSpecifier as ts.StringLiteral).text;
260
+ if (!isTypeOnlyExport(statement)) {
261
+ const target = resolveSpecifier(spec, file);
262
+ if (target) {
263
+ const clause = statement.exportClause;
264
+ if (clause && ts.isNamedExports(clause)) {
265
+ for (const element of clause.elements) {
266
+ if (element.isTypeOnly) {
267
+ continue;
268
+ }
269
+ info.reExports.push({
270
+ target,
271
+ exportedName: element.name.text,
272
+ localName: (element.propertyName ?? element.name).text,
273
+ });
274
+ }
275
+ } else {
276
+ info.starReExports.push(target);
277
+ }
278
+ }
279
+ }
280
+ continue;
281
+ }
282
+
283
+ // Anything else is real code, so this module is not a pass-through barrel.
284
+ info.isPureBarrel = false;
285
+
286
+ if (ts.isVariableStatement(statement)) {
287
+ const isLet = !(statement.declarationList.flags & ts.NodeFlags.Const);
288
+ for (const decl of statement.declarationList.declarations) {
289
+ const name = decl.name.getText(sourceFile);
290
+ if (isLet) {
291
+ info.moduleMutableState.push(`let ${name}`);
292
+ } else if (decl.initializer) {
293
+ const ctor = /^new (Map|Set|WeakMap|WeakSet)\b/.exec(
294
+ decl.initializer.getText(sourceFile),
295
+ );
296
+ if (ctor) {
297
+ info.moduleMutableState.push(`const ${name} = new ${ctor[1]}()`);
298
+ }
299
+ }
300
+ }
301
+ }
302
+ }
303
+
304
+ // Value-position `React.useState(...)`. A type reference like
305
+ // `React.ReactNode` parses as a QualifiedName rather than a
306
+ // PropertyAccessExpression, so types never reach this branch.
307
+ if (reactNamespaces.size > 0) {
308
+ const visit = (node: ts.Node): void => {
309
+ if (
310
+ ts.isPropertyAccessExpression(node) &&
311
+ ts.isIdentifier(node.expression) &&
312
+ reactNamespaces.has(node.expression.text) &&
313
+ CLIENT_APIS.has(node.name.text)
314
+ ) {
315
+ info.clientAPIs.push(`${node.expression.text}.${node.name.text}`);
316
+ }
317
+ ts.forEachChild(node, visit);
318
+ };
319
+ ts.forEachChild(sourceFile, visit);
320
+ }
321
+
322
+ return info;
323
+ }
324
+
325
+ /**
326
+ * Walk the runtime import graph from `entry` and report every client-only
327
+ * surface it reaches.
328
+ *
329
+ * `packages/core/package.json` declares `sideEffects` as a narrow allowlist
330
+ * (`*.stylex.ts`, `componentStyles.ts`, `*.css`), so every other module is
331
+ * side-effect free and a bundler may drop unused re-exports. A pure
332
+ * pass-through barrel is therefore followed by *used export* rather than
333
+ * wholesale — otherwise importing `mergeProps` from `../utils` would appear to
334
+ * drag in every unrelated sibling the barrel happens to re-export.
335
+ *
336
+ * @param ownDir When set, `'use client'` directives on files directly inside
337
+ * this directory are ignored, so the walk reports what the component's code
338
+ * actually *needs* rather than what it is currently *marked* as.
339
+ */
340
+ function findClientSurfaces(
341
+ entry: string,
342
+ ownDir: string | null,
343
+ ): {reason: string; file: string; via: string}[] {
344
+ const problems: {reason: string; file: string; via: string}[] = [];
345
+ const visited = new Set<string>();
346
+ const queue: [string, Set<string> | '*', string[]][] = [
347
+ [entry, '*', [relative(SRC_DIR, entry)]],
348
+ ];
349
+
350
+ while (queue.length > 0) {
351
+ const [file, names, via] = queue.pop()!;
352
+ if (!/\.(tsx?|jsx?|mjs)$/.test(file)) {
353
+ continue;
354
+ }
355
+ const key = `${file}|${names === '*' ? '*' : [...names].sort().join(',')}`;
356
+ if (visited.has(key)) {
357
+ continue;
358
+ }
359
+ visited.add(key);
360
+
361
+ const info = parseModule(file);
362
+ const chain = via.join(' -> ');
363
+ const insideOwnDir = ownDir !== null && dirname(file) === ownDir;
364
+
365
+ if (info.hasUseClient && !insideOwnDir) {
366
+ problems.push({
367
+ reason: 'depends on a "use client" module',
368
+ file: info.rel,
369
+ via: chain,
370
+ });
371
+ }
372
+ if (info.clientAPIs.length > 0) {
373
+ const apis = [...new Set(info.clientAPIs)].join(', ');
374
+ problems.push({
375
+ reason: `imports ${apis} from react`,
376
+ file: info.rel,
377
+ via: chain,
378
+ });
379
+ }
380
+ if (info.reactDom.length > 0) {
381
+ problems.push({
382
+ reason: `imports ${info.reactDom.join(', ')}`,
383
+ file: info.rel,
384
+ via: chain,
385
+ });
386
+ }
387
+ if (info.moduleMutableState.length > 0) {
388
+ problems.push({
389
+ reason: `module-level mutable state (${info.moduleMutableState.join('; ')})`,
390
+ file: info.rel,
391
+ via: chain,
392
+ });
393
+ }
394
+
395
+ const push = (target: string, next: Set<string> | '*') =>
396
+ queue.push([target, next, [...via, relative(SRC_DIR, target)]]);
397
+
398
+ if (info.isPureBarrel && names !== '*') {
399
+ for (const wanted of names) {
400
+ const providers = info.reExports.filter(r => r.exportedName === wanted);
401
+ if (providers.length > 0) {
402
+ for (const p of providers) {
403
+ push(p.target, new Set([p.localName]));
404
+ }
405
+ } else {
406
+ for (const t of info.starReExports) {
407
+ push(t, new Set([wanted]));
408
+ }
409
+ }
410
+ }
411
+ for (const imp of info.imports) {
412
+ push(imp.target, imp.names);
413
+ }
414
+ } else {
415
+ for (const imp of info.imports) {
416
+ push(imp.target, imp.names);
417
+ }
418
+ for (const r of info.reExports) {
419
+ push(r.target, new Set([r.localName]));
420
+ }
421
+ for (const t of info.starReExports) {
422
+ push(t, '*');
423
+ }
424
+ }
425
+ }
426
+
427
+ return problems;
428
+ }
429
+
430
+ interface Component {
431
+ subpath: string;
432
+ dir: string;
433
+ entry: string;
434
+ /** Source files in the component's own directory that carry the directive. */
435
+ directiveFiles: string[];
436
+ }
437
+
438
+ /**
439
+ * Every component the package exposes as a `./Name` subpath backed by
440
+ * `./src/Name/index.ts`. Driving off the export map keeps the test aligned
441
+ * with the package's real public surface.
442
+ */
443
+ function publicComponents(): Component[] {
444
+ const pkg = JSON.parse(readFileSync(PKG_JSON, 'utf-8')) as {
445
+ exports?: Record<string, {source?: string}>;
446
+ };
447
+ const components: Component[] = [];
448
+ for (const [subpath, entryPoint] of Object.entries(pkg.exports ?? {})) {
449
+ const source = entryPoint?.source;
450
+ if (!source) {
451
+ continue;
452
+ }
453
+ const match = /^\.\/src\/([^/]+)\/index\.ts$/.exec(source);
454
+ if (!match) {
455
+ continue;
456
+ }
457
+ const dir = join(SRC_DIR, match[1]);
458
+ const entry = join(dir, 'index.ts');
459
+ if (!existsSync(entry)) {
460
+ continue;
461
+ }
462
+ const directiveFiles = readdirSync(dir)
463
+ .filter(isSourceFile)
464
+ .filter(name => parseModule(join(dir, name)).hasUseClient)
465
+ .map(name => `${match[1]}/${name}`);
466
+ components.push({subpath, dir, entry, directiveFiles});
467
+ }
468
+ return components;
469
+ }
470
+
471
+ const COMPONENTS = publicComponents();
472
+
473
+ const format = (problems: {reason: string; file: string; via: string}[]) =>
474
+ [...new Map(problems.map(p => [`${p.file}|${p.reason}`, p])).values()]
475
+ .map(p => ` ${p.file} ${p.reason}\n via ${p.via}`)
476
+ .join('\n');
477
+
478
+ /** Parse a source string as if it were a file inside `SRC_DIR`. */
479
+ const parse = (source: string) =>
480
+ parseSource(source, join(SRC_DIR, 'probe.tsx'));
481
+
482
+ describe('parseSource', () => {
483
+ it('reads a client API from a named react import', () => {
484
+ expect(
485
+ parse(`import {useState} from 'react';\nexport const a = 1;`).clientAPIs,
486
+ ).toEqual(['useState']);
487
+ });
488
+
489
+ it('reads a client API reached through a default React binding', () => {
490
+ const info = parse(
491
+ `import React from 'react';\nexport const a = () => React.useState(0);`,
492
+ );
493
+ expect(info.clientAPIs).toEqual(['React.useState']);
494
+ });
495
+
496
+ it('reads a client API reached through a namespace React binding', () => {
497
+ const info = parse(
498
+ `import * as React from 'react';\nexport const C = React.createContext(false);`,
499
+ );
500
+ expect(info.clientAPIs).toEqual(['React.createContext']);
501
+ });
502
+
503
+ it('reads both halves of a mixed default-and-named react import', () => {
504
+ const info = parse(
505
+ `import React, {useRef} from 'react';\nexport const a = () => React.useState(0) && useRef(null);`,
506
+ );
507
+ expect(info.clientAPIs.sort()).toEqual(['React.useState', 'useRef']);
508
+ });
509
+
510
+ it('does not flag a React binding used only in type position', () => {
511
+ const info = parse(
512
+ `import * as React from 'react';\nexport type P = {a: React.ReactNode; b: React.KeyboardEvent};`,
513
+ );
514
+ expect(info.clientAPIs).toEqual([]);
515
+ });
516
+
517
+ it('treats React 19 `use` as a client API', () => {
518
+ // `use(Context)` is a context read, exactly as client-only as useContext.
519
+ expect(
520
+ parse(`import {use} from 'react';\nexport const a = 1;`).clientAPIs,
521
+ ).toEqual(['use']);
522
+ });
523
+
524
+ it.each(['useActionState', 'useEffectEvent', 'startTransition'])(
525
+ 'treats React 19 `%s` as a client API',
526
+ api => {
527
+ expect(
528
+ parse(`import {${api}} from 'react';\nexport const a = 1;`).clientAPIs,
529
+ ).toEqual([api]);
530
+ },
531
+ );
532
+
533
+ it('reads a client API imported under an alias', () => {
534
+ expect(
535
+ parse(
536
+ `import {useActionState as useSubmitState} from 'react';\nexport const a = 1;`,
537
+ ).clientAPIs,
538
+ ).toEqual(['useActionState']);
539
+ });
540
+
541
+ it('reads useEffectEvent reached through a default React binding', () => {
542
+ expect(
543
+ parse(
544
+ `import React from 'react';\nexport const a = () => React.useEffectEvent(() => {});`,
545
+ ).clientAPIs,
546
+ ).toEqual(['React.useEffectEvent']);
547
+ });
548
+
549
+ it('sees `use client` behind another prologue directive', () => {
550
+ expect(
551
+ parse(`'use strict';\n'use client';\nexport const a = 1;`).hasUseClient,
552
+ ).toBe(true);
553
+ });
554
+
555
+ it('ignores a `use client` string that follows real code', () => {
556
+ expect(parse(`export const a = 1;\n'use client';`).hasUseClient).toBe(
557
+ false,
558
+ );
559
+ });
560
+ });
561
+
562
+ describe('CLIENT_APIS', () => {
563
+ it('covers every client-only hook the react-server build omits', () => {
564
+ // React's own builds are the ground truth for "works on the server": the
565
+ // react-server condition maps to a deliberate allowlist, and the exports
566
+ // it drops are exactly the client-only surface. Every hook-shaped export
567
+ // in the client build but not the server build must be in CLIENT_APIS,
568
+ // or both invariants below go blind to it. The subset holds in one
569
+ // direction only: `use`, useId, useMemo and useCallback are server-legal
570
+ // but deliberately over-approximated as client APIs above.
571
+ const nodeRequire = createRequire(import.meta.url);
572
+ const cjs = join(dirname(nodeRequire.resolve('react/package.json')), 'cjs');
573
+ const exportsOf = (file: string) =>
574
+ new Set(
575
+ [
576
+ ...readFileSync(join(cjs, file), 'utf-8').matchAll(
577
+ /exports\.(\w+)\s*=/g,
578
+ ),
579
+ ].map(m => m[1]),
580
+ );
581
+ const client = exportsOf('react.production.js');
582
+ const server = exportsOf('react.react-server.production.js');
583
+ const missing = [...client].filter(
584
+ name =>
585
+ (/^use[A-Z]/.test(name) || name === 'startTransition') &&
586
+ !server.has(name) &&
587
+ !CLIENT_APIS.has(name),
588
+ );
589
+ expect(
590
+ missing,
591
+ `\nreact marks these exports client-only (absent from its react-server\n` +
592
+ `build) but CLIENT_APIS does not list them. Add them here and to\n` +
593
+ `scripts/check-use-client.mjs:\n ${missing.join(', ')}\n`,
594
+ ).toEqual([]);
595
+ });
596
+
597
+ it('stays in sync with scripts/check-use-client.mjs', () => {
598
+ const script = readFileSync(
599
+ join(SRC_DIR, '..', '..', '..', 'scripts', 'check-use-client.mjs'),
600
+ 'utf-8',
601
+ );
602
+ const literal = /const CLIENT_APIS = \[([\s\S]*?)\];/.exec(script);
603
+ const names =
604
+ literal === null
605
+ ? []
606
+ : [
607
+ ...literal[1]
608
+ .split('\n')
609
+ .filter(line => !line.trim().startsWith('//'))
610
+ .join('\n')
611
+ .matchAll(/'([^']+)'/g),
612
+ ].map(m => m[1]);
613
+ expect(new Set(names)).toEqual(CLIENT_APIS);
614
+ });
615
+ });
616
+
617
+ describe('RSC server/client boundary (#823)', () => {
618
+ it('finds the public component entry points', () => {
619
+ // The export map lists 100+ component subpaths; a floor this close to the
620
+ // real count catches a resolution bug that silently shrinks the covered set
621
+ // and turns both invariants below into no-ops.
622
+ expect(COMPONENTS.length).toBeGreaterThan(95);
623
+ });
624
+
625
+ it('does not mark server-safe components as "use client"', () => {
626
+ const stale: string[] = [];
627
+ for (const component of COMPONENTS) {
628
+ if (component.directiveFiles.length === 0) {
629
+ continue;
630
+ }
631
+ // Ignore this component's own directives so the graph reports what its
632
+ // code needs, not what it is currently labelled.
633
+ const problems = findClientSurfaces(component.entry, component.dir);
634
+ if (problems.length === 0) {
635
+ stale.push(
636
+ ` ${component.subpath} is server-safe but still carries 'use client' in: ${component.directiveFiles.join(', ')}`,
637
+ );
638
+ }
639
+ }
640
+ expect(
641
+ stale.join('\n'),
642
+ `\nThese components import no client-only surface, so the directive pins them\n` +
643
+ `to the client bundle for nothing. Remove it (source file *and* index.ts):\n`,
644
+ ).toBe('');
645
+ });
646
+
647
+ it('keeps directive-free components free of client-only imports', () => {
648
+ const leaked: string[] = [];
649
+ for (const component of COMPONENTS) {
650
+ if (component.directiveFiles.length > 0) {
651
+ continue;
652
+ }
653
+ const problems = findClientSurfaces(component.entry, null);
654
+ if (problems.length > 0) {
655
+ leaked.push(` ${component.subpath}:\n${format(problems)}`);
656
+ }
657
+ }
658
+ expect(
659
+ leaked.join('\n'),
660
+ `\nThese components ship without 'use client', so they are server modules —\n` +
661
+ `but they reach a client-only surface. Either drop the client dependency or\n` +
662
+ `restore the directive. scripts/check-use-client.mjs cannot see this:\n`,
663
+ ).toBe('');
664
+ });
665
+ });
@@ -42,6 +42,7 @@ import {
42
42
  colorDefaults,
43
43
  spacingDefaults,
44
44
  sizeDefaults,
45
+ focusDefaults,
45
46
  radiusDefaults,
46
47
  shadowDefaults,
47
48
  durationDefaults,
@@ -74,6 +75,7 @@ export type CoreTokenName =
74
75
  | keyof typeof colorDefaults
75
76
  | keyof typeof spacingDefaults
76
77
  | keyof typeof sizeDefaults
78
+ | keyof typeof focusDefaults
77
79
  | keyof typeof radiusDefaults
78
80
  | keyof typeof shadowDefaults
79
81
  | keyof typeof durationDefaults
@@ -377,6 +379,7 @@ export const tokenDefaults: Record<string, string> = {
377
379
  ...colorDefaults,
378
380
  ...spacingDefaults,
379
381
  ...sizeDefaults,
382
+ ...focusDefaults,
380
383
  ...radiusDefaults,
381
384
  ...shadowDefaults,
382
385
  ...durationDefaults,
@@ -23,6 +23,23 @@ const defaultInput = {
23
23
  },
24
24
  };
25
25
 
26
+ describe('focus outline tokens', () => {
27
+ it('emits a focus ring override into the theme scope', () => {
28
+ const theme = defineTheme({
29
+ name: 'brand',
30
+ tokens: {
31
+ '--focus-outline-color': '#FF00FF',
32
+ '--focus-outline-width': '4px',
33
+ },
34
+ });
35
+
36
+ const {component} = generateThemeCSS(theme);
37
+
38
+ expect(component).toContain('--focus-outline-color: #FF00FF;');
39
+ expect(component).toContain('--focus-outline-width: 4px;');
40
+ });
41
+ });
42
+
26
43
  describe('generateThemeRules', () => {
27
44
  const theme = defineTheme(defaultInput);
28
45
  const rules = generateThemeRules(theme);