@oxyhq/bloom 0.68.0 → 0.69.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.
Files changed (116) hide show
  1. package/lib/commonjs/animated-check/AnimatedCheck.js +22 -93
  2. package/lib/commonjs/animated-check/AnimatedCheck.js.map +1 -1
  3. package/lib/commonjs/avatar/Avatar.js +6 -24
  4. package/lib/commonjs/avatar/Avatar.js.map +1 -1
  5. package/lib/commonjs/avatar/AvatarRing.js +14 -30
  6. package/lib/commonjs/avatar/AvatarRing.js.map +1 -1
  7. package/lib/commonjs/bottom-sheet/index.js +9 -4
  8. package/lib/commonjs/bottom-sheet/index.js.map +1 -1
  9. package/lib/commonjs/connection-status/index.js +16 -7
  10. package/lib/commonjs/connection-status/index.js.map +1 -1
  11. package/lib/commonjs/connection-status/netinfo.js +119 -0
  12. package/lib/commonjs/connection-status/netinfo.js.map +1 -0
  13. package/lib/commonjs/hooks/haptics-module.js +109 -0
  14. package/lib/commonjs/hooks/haptics-module.js.map +1 -0
  15. package/lib/commonjs/hooks/useHaptics.js +6 -8
  16. package/lib/commonjs/hooks/useHaptics.js.map +1 -1
  17. package/lib/commonjs/loading/SpinnerIcon.js +22 -55
  18. package/lib/commonjs/loading/SpinnerIcon.js.map +1 -1
  19. package/lib/commonjs/theme/adaptive-colors.js +20 -4
  20. package/lib/commonjs/theme/adaptive-colors.js.map +1 -1
  21. package/lib/commonjs/theme/color-scope/style-builder.js +107 -6
  22. package/lib/commonjs/theme/color-scope/style-builder.js.map +1 -1
  23. package/lib/commonjs/theme/init-css-interop.js +13 -11
  24. package/lib/commonjs/theme/init-css-interop.js.map +1 -1
  25. package/lib/module/animated-check/AnimatedCheck.js +22 -91
  26. package/lib/module/animated-check/AnimatedCheck.js.map +1 -1
  27. package/lib/module/avatar/Avatar.js +1 -19
  28. package/lib/module/avatar/Avatar.js.map +1 -1
  29. package/lib/module/avatar/AvatarRing.js +7 -23
  30. package/lib/module/avatar/AvatarRing.js.map +1 -1
  31. package/lib/module/bottom-sheet/index.js +9 -4
  32. package/lib/module/bottom-sheet/index.js.map +1 -1
  33. package/lib/module/connection-status/index.js +16 -6
  34. package/lib/module/connection-status/index.js.map +1 -1
  35. package/lib/module/connection-status/netinfo.js +114 -0
  36. package/lib/module/connection-status/netinfo.js.map +1 -0
  37. package/lib/module/hooks/haptics-module.js +104 -0
  38. package/lib/module/hooks/haptics-module.js.map +1 -0
  39. package/lib/module/hooks/useHaptics.js +7 -11
  40. package/lib/module/hooks/useHaptics.js.map +1 -1
  41. package/lib/module/loading/SpinnerIcon.js +13 -46
  42. package/lib/module/loading/SpinnerIcon.js.map +1 -1
  43. package/lib/module/loading/index.web.js +2 -2
  44. package/lib/module/theme/adaptive-colors.js +20 -4
  45. package/lib/module/theme/adaptive-colors.js.map +1 -1
  46. package/lib/module/theme/color-scope/style-builder.js +107 -6
  47. package/lib/module/theme/color-scope/style-builder.js.map +1 -1
  48. package/lib/module/theme/init-css-interop.js +13 -11
  49. package/lib/module/theme/init-css-interop.js.map +1 -1
  50. package/lib/typescript/commonjs/animated-check/AnimatedCheck.d.ts.map +1 -1
  51. package/lib/typescript/commonjs/avatar/Avatar.d.ts.map +1 -1
  52. package/lib/typescript/commonjs/avatar/AvatarRing.d.ts.map +1 -1
  53. package/lib/typescript/commonjs/bottom-sheet/index.d.ts.map +1 -1
  54. package/lib/typescript/commonjs/connection-status/index.d.ts.map +1 -1
  55. package/lib/typescript/commonjs/connection-status/netinfo.d.ts +69 -0
  56. package/lib/typescript/commonjs/connection-status/netinfo.d.ts.map +1 -0
  57. package/lib/typescript/commonjs/hooks/haptics-module.d.ts +58 -0
  58. package/lib/typescript/commonjs/hooks/haptics-module.d.ts.map +1 -0
  59. package/lib/typescript/commonjs/hooks/useHaptics.d.ts.map +1 -1
  60. package/lib/typescript/commonjs/loading/SpinnerIcon.d.ts +9 -2
  61. package/lib/typescript/commonjs/loading/SpinnerIcon.d.ts.map +1 -1
  62. package/lib/typescript/commonjs/theme/adaptive-colors.d.ts.map +1 -1
  63. package/lib/typescript/commonjs/theme/color-scope/style-builder.d.ts.map +1 -1
  64. package/lib/typescript/commonjs/theme/init-css-interop.d.ts.map +1 -1
  65. package/lib/typescript/module/animated-check/AnimatedCheck.d.ts.map +1 -1
  66. package/lib/typescript/module/avatar/Avatar.d.ts.map +1 -1
  67. package/lib/typescript/module/avatar/AvatarRing.d.ts.map +1 -1
  68. package/lib/typescript/module/bottom-sheet/index.d.ts.map +1 -1
  69. package/lib/typescript/module/connection-status/index.d.ts.map +1 -1
  70. package/lib/typescript/module/connection-status/netinfo.d.ts +69 -0
  71. package/lib/typescript/module/connection-status/netinfo.d.ts.map +1 -0
  72. package/lib/typescript/module/hooks/haptics-module.d.ts +58 -0
  73. package/lib/typescript/module/hooks/haptics-module.d.ts.map +1 -0
  74. package/lib/typescript/module/hooks/useHaptics.d.ts.map +1 -1
  75. package/lib/typescript/module/loading/SpinnerIcon.d.ts +9 -2
  76. package/lib/typescript/module/loading/SpinnerIcon.d.ts.map +1 -1
  77. package/lib/typescript/module/theme/adaptive-colors.d.ts.map +1 -1
  78. package/lib/typescript/module/theme/color-scope/style-builder.d.ts.map +1 -1
  79. package/lib/typescript/module/theme/init-css-interop.d.ts.map +1 -1
  80. package/package.json +2 -2
  81. package/src/__tests__/color-scope-nativewind.test.ts +135 -0
  82. package/src/__tests__/connection-status-netinfo.test.tsx +203 -0
  83. package/src/__tests__/optional-peer-imports.test.ts +406 -0
  84. package/src/__tests__/svg-required-peer.test.tsx +75 -0
  85. package/src/__tests__/useHaptics.test.tsx +203 -16
  86. package/src/animated-check/AnimatedCheck.tsx +19 -86
  87. package/src/avatar/Avatar.tsx +1 -11
  88. package/src/avatar/AvatarRing.tsx +7 -15
  89. package/src/bottom-sheet/index.tsx +9 -4
  90. package/src/connection-status/index.tsx +16 -6
  91. package/src/connection-status/netinfo.ts +131 -0
  92. package/src/hooks/haptics-module.ts +118 -0
  93. package/src/hooks/useHaptics.tsx +6 -9
  94. package/src/loading/SpinnerIcon.tsx +13 -52
  95. package/src/loading/index.web.ts +2 -2
  96. package/src/theme/adaptive-colors.ts +20 -4
  97. package/src/theme/color-scope/style-builder.ts +118 -6
  98. package/src/theme/init-css-interop.ts +13 -11
  99. package/lib/commonjs/avatar/svg-module.js +0 -15
  100. package/lib/commonjs/avatar/svg-module.js.map +0 -1
  101. package/lib/commonjs/utils/lazy-require.js +0 -37
  102. package/lib/commonjs/utils/lazy-require.js.map +0 -1
  103. package/lib/module/avatar/svg-module.js +0 -11
  104. package/lib/module/avatar/svg-module.js.map +0 -1
  105. package/lib/module/utils/lazy-require.js +0 -33
  106. package/lib/module/utils/lazy-require.js.map +0 -1
  107. package/lib/typescript/commonjs/avatar/svg-module.d.ts +0 -8
  108. package/lib/typescript/commonjs/avatar/svg-module.d.ts.map +0 -1
  109. package/lib/typescript/commonjs/utils/lazy-require.d.ts +0 -15
  110. package/lib/typescript/commonjs/utils/lazy-require.d.ts.map +0 -1
  111. package/lib/typescript/module/avatar/svg-module.d.ts +0 -8
  112. package/lib/typescript/module/avatar/svg-module.d.ts.map +0 -1
  113. package/lib/typescript/module/utils/lazy-require.d.ts +0 -15
  114. package/lib/typescript/module/utils/lazy-require.d.ts.map +0 -1
  115. package/src/avatar/svg-module.ts +0 -11
  116. package/src/utils/lazy-require.ts +0 -31
@@ -0,0 +1,406 @@
1
+ // A source gate for the two ways Bloom can get an optional peer's load shape
2
+ // wrong. They fail in opposite directions and only one of them is loud.
3
+ //
4
+ // (1) OPTIONAL PEER, STATIC IMPORT — takes a consumer's native bundle down
5
+ // before it ever runs. Those two statements cannot both be true: Metro resolves
6
+ // every static import in the eager graph, so omitting an "optional" peer does
7
+ // not degrade, it aborts the whole bundle with `Unable to resolve module
8
+ // <peer>`, naming a package the app never mentions.
9
+ // `@react-native-community/netinfo` was exactly this shape (with an
10
+ // empty-string version range on top), and the same defect had already shipped
11
+ // fleet-wide from `@oxyhq/protocol`'s `expo-crypto`.
12
+ //
13
+ // (2) DYNAMIC REQUIRE WITH AN UNEVALUABLE SPECIFIER — silent, and therefore
14
+ // worse. Metro can only collect a dependency whose specifier it can evaluate
15
+ // statically; anything else it rewrites into an inlined thrower ("Dynamic
16
+ // require defined at line N; not supported by Metro"), so the call resolves
17
+ // NOTHING from a consumer's `node_modules` whether or not the package is
18
+ // installed. A caller that catches the throw and degrades then degrades ALWAYS.
19
+ // Bloom's deleted `lazyRequire()` helper took the specifier as a function
20
+ // PARAMETER — the unevaluable case — and silently killed haptics, the squircle
21
+ // avatar clip, the spinner and `BloomColorScope`'s native scoping on every
22
+ // device for as long as it shipped. Only jest (CommonJS, with a real dynamic
23
+ // `require`) made it look like it worked, which is why no runtime suite could
24
+ // catch it and why this file, a STATIC scan, is the gate.
25
+ //
26
+ // The shape that satisfies both is narrower than "a require in a try", and the
27
+ // difference is not cosmetic. Verified against metro 0.83.5's own
28
+ // `collectDependencies` + `isOptionalDependency`:
29
+ //
30
+ // require(functionParameter) -> no dependency at all, thrower
31
+ // const n = 'pkg'; require(n) in try -> optional (Metro folds the const)
32
+ // require('pkg') nested in an if in try -> NOT optional
33
+ // require('pkg') direct statement of try -> optional
34
+ // require('pkg') with no try -> NOT optional
35
+ //
36
+ // `isOptionalDependency` walks up from the call and returns at the FIRST
37
+ // BlockStatement it meets, marking the dependency optional only if that block
38
+ // belongs to a `try`. One `if`/`else` of nesting loses it — and a non-optional
39
+ // dependency that cannot be resolved fails the BUILD, which is the very failure
40
+ // the boundary exists to prevent. So the rule is: a STRING LITERAL, as a DIRECT
41
+ // STATEMENT of a `try` block. Both halves are asserted below.
42
+ //
43
+ // Type-only imports are deliberately not flagged: they emit no require, so they
44
+ // cannot break a bundle. (Whether they leak into consumers' typechecks is the
45
+ // separate concern the split `react-native` export condition closes.)
46
+
47
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
48
+ import { join } from 'node:path';
49
+ import ts from 'typescript';
50
+
51
+ const SRC = join(__dirname, '..');
52
+ const PKG = JSON.parse(readFileSync(join(SRC, '..', 'package.json'), 'utf8')) as {
53
+ peerDependencies: Record<string, string>;
54
+ peerDependenciesMeta: Record<string, { optional?: boolean }>;
55
+ };
56
+
57
+ const OPTIONAL_PEERS = Object.entries(PKG.peerDependenciesMeta)
58
+ .filter(([, meta]) => meta.optional === true)
59
+ .map(([name]) => name);
60
+
61
+ /**
62
+ * Static imports of an optional peer that are known-safe, each because the
63
+ * package is guaranteed present in the only environment that reaches the file.
64
+ * An entry here is an assertion that the peer CANNOT actually be absent — not a
65
+ * TODO. The stale-exemption test below fails if one stops being imported, so
66
+ * this list can never quietly outlive its subject.
67
+ */
68
+ const ALLOWED: { peer: string; file: string; why: string }[] = [
69
+ {
70
+ peer: 'expo-font',
71
+ file: 'fonts/FontLoader.native.tsx',
72
+ why: 'expo-font is a hard dependency of `expo` itself, so any app that can reach a .native file has it',
73
+ },
74
+ {
75
+ peer: 'react-dom',
76
+ file: 'portal/index.web.tsx',
77
+ why: 'react-native-web depends on react-dom, so a web bundle rendering Bloom always has it',
78
+ },
79
+ {
80
+ peer: 'expo-router',
81
+ file: 'scroll/index.web.tsx',
82
+ why: 'scroll restoration is expo-router-only by design — this is the import that keeps BloomProvider out of Vite/SPA consumers (see AGENTS.md "App Root Provider")',
83
+ },
84
+ ];
85
+
86
+ /**
87
+ * Every optional peer Bloom loads at RUNTIME, and the one file allowed to load
88
+ * it. The test below asserts this table and the source tree describe the same
89
+ * set in both directions: a boundary that moves file, and a peer that acquires a
90
+ * second undocumented load site, both fail here.
91
+ */
92
+ const DYNAMIC_BOUNDARIES: { peer: string; file: string }[] = [
93
+ { peer: '@react-native-community/netinfo', file: 'connection-status/netinfo.ts' },
94
+ { peer: 'expo-haptics', file: 'hooks/haptics-module.ts' },
95
+ { peer: 'nativewind', file: 'theme/color-scope/style-builder.ts' },
96
+ { peer: 'expo-router', file: 'theme/adaptive-colors.ts' },
97
+ ];
98
+
99
+ /**
100
+ * The ONLY files allowed to call `require()` with a non-literal specifier.
101
+ *
102
+ * A local `const` holding a literal does in fact survive Metro (its collector
103
+ * evaluates the argument and folds the constant), so this rule is stricter than
104
+ * Metro strictly needs. That is deliberate: "literal" is a property a reader and
105
+ * a scanner can both check at a glance, while "statically evaluable" is a
106
+ * property of a whole scope that silently stops holding the moment the value
107
+ * becomes a parameter, a re-assignment, or a computed string — which is exactly
108
+ * how `lazyRequire` passed review. An entry here is a claim that the file has a
109
+ * reason to hide the specifier from a bundler AND nothing to lose if a bundler
110
+ * cannot resolve it.
111
+ */
112
+ const VARIABLE_REQUIRE_ALLOWED: { file: string; why: string }[] = [
113
+ {
114
+ file: 'theme/init-css-interop.ts',
115
+ why: 'react-native-css-interop is neither a dependency nor a declared peer, so the indirection keeps a plain-text specifier out of reach of bundlers that would try to resolve a package Bloom never asked for; the const is in the same scope, so Metro folds it and treats the call as an optional literal anyway, and if a bundler did drop it there is nothing to lose (the `setFlag` it probes for exists in no shipped version, native gets the flag from the host Tailwind build, and web is fixed by the CSS variable set alongside it)',
116
+ },
117
+ ];
118
+
119
+ function sourceFiles(dir: string, out: string[] = []): string[] {
120
+ for (const entry of readdirSync(dir)) {
121
+ if (entry === '__tests__' || entry === 'node_modules') continue;
122
+ const full = join(dir, entry);
123
+ if (statSync(full).isDirectory()) {
124
+ sourceFiles(full, out);
125
+ } else if (/\.tsx?$/.test(entry)) {
126
+ out.push(full);
127
+ }
128
+ }
129
+ return out;
130
+ }
131
+
132
+ /** `pkg` itself or any of its subpaths (`react-dom/client`). */
133
+ function specifierTargets(specifier: string, pkg: string): boolean {
134
+ return specifier === pkg || specifier.startsWith(`${pkg}/`);
135
+ }
136
+
137
+ function parse(file: string): ts.SourceFile {
138
+ return ts.createSourceFile(
139
+ file,
140
+ readFileSync(file, 'utf8'),
141
+ ts.ScriptTarget.Latest,
142
+ /* setParentNodes */ true,
143
+ file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
144
+ );
145
+ }
146
+
147
+ /** Every value-position `import`/`export ... from` specifier in a file. */
148
+ function valueImportSpecifiers(source: ts.SourceFile): { specifier: string; line: number }[] {
149
+ const found: { specifier: string; line: number }[] = [];
150
+
151
+ for (const statement of source.statements) {
152
+ const isImport = ts.isImportDeclaration(statement);
153
+ const isExportFrom = ts.isExportDeclaration(statement) && statement.moduleSpecifier !== undefined;
154
+ if (!isImport && !isExportFrom) continue;
155
+
156
+ // `import type … from` / `export type … from` emit nothing at runtime.
157
+ if (isImport && statement.importClause?.isTypeOnly) continue;
158
+ if (isExportFrom && statement.isTypeOnly) continue;
159
+
160
+ const moduleSpecifier = isImport ? statement.moduleSpecifier : statement.moduleSpecifier;
161
+ if (!moduleSpecifier || !ts.isStringLiteral(moduleSpecifier)) continue;
162
+
163
+ found.push({
164
+ specifier: moduleSpecifier.text,
165
+ line: source.getLineAndCharacterOfPosition(statement.getStart(source)).line + 1,
166
+ });
167
+ }
168
+
169
+ return found;
170
+ }
171
+
172
+ describe('optional peers are never static imports', () => {
173
+ const files = sourceFiles(SRC);
174
+
175
+ // Vacuity floors: a broken traversal, or a manifest read that silently
176
+ // produced an empty peer set, would otherwise report a clean sweep.
177
+ it('scans the whole source tree against a non-empty optional-peer set', () => {
178
+ expect(files.length).toBeGreaterThan(200);
179
+ expect(files.some((f) => f.endsWith(join('connection-status', 'netinfo.ts')))).toBe(true);
180
+ expect(OPTIONAL_PEERS.length).toBeGreaterThanOrEqual(5);
181
+ expect(OPTIONAL_PEERS).toContain('@react-native-community/netinfo');
182
+ });
183
+
184
+ it('declares a real version range for every peer', () => {
185
+ const offenders: string[] = [];
186
+
187
+ for (const [name, range] of Object.entries(PKG.peerDependencies)) {
188
+ // The wart this guards is `""` — a range that constrains nothing and that
189
+ // no package manager can act on. `*` is a deliberate "any version"; every
190
+ // other range has to carry a number.
191
+ if (range.trim() !== '*' && !/\d/.test(range)) {
192
+ offenders.push(`${name}: ${JSON.stringify(range)}`);
193
+ }
194
+ }
195
+
196
+ expect(offenders).toEqual([]);
197
+ });
198
+
199
+ it('lists every optional peer in peerDependencies', () => {
200
+ const missing = OPTIONAL_PEERS.filter((peer) => !(peer in PKG.peerDependencies));
201
+ expect(missing).toEqual([]);
202
+ });
203
+
204
+ it('never statically imports one outside the documented allowlist', () => {
205
+ const offenders: string[] = [];
206
+
207
+ for (const file of files) {
208
+ const relative = file.slice(SRC.length + 1);
209
+ for (const { specifier, line } of valueImportSpecifiers(parse(file))) {
210
+ const peer = OPTIONAL_PEERS.find((candidate) => specifierTargets(specifier, candidate));
211
+ if (!peer) continue;
212
+ if (ALLOWED.some((entry) => entry.peer === peer && entry.file === relative)) continue;
213
+ offenders.push(
214
+ `${relative}:${line} imports optional peer '${specifier}' — load it with ` +
215
+ "`try { require('<literal>') } catch` (see connection-status/netinfo.ts) " +
216
+ 'or make it a non-optional peer',
217
+ );
218
+ }
219
+ }
220
+
221
+ expect(offenders).toEqual([]);
222
+ });
223
+
224
+ // An exemption that no longer describes anything is worse than no exemption:
225
+ // it silently pre-approves the next import someone adds to that file.
226
+ it('keeps no stale allowlist entries', () => {
227
+ const stale: string[] = [];
228
+
229
+ for (const entry of ALLOWED) {
230
+ const specifiers = valueImportSpecifiers(parse(join(SRC, entry.file)));
231
+ if (!specifiers.some(({ specifier }) => specifierTargets(specifier, entry.peer))) {
232
+ stale.push(`${entry.file} no longer imports '${entry.peer}' — drop the allowlist entry`);
233
+ }
234
+ }
235
+
236
+ expect(stale).toEqual([]);
237
+ });
238
+ });
239
+
240
+ /**
241
+ * Metro's own optionality rule, mirrored on the TypeScript AST.
242
+ *
243
+ * Transcribed from metro 0.83.5 `src/ModuleGraph/worker/collectDependencies.js`
244
+ * → `isOptionalDependency`: walk up from the call, allowing at most three
245
+ * statement ancestors, and return as soon as a BlockStatement is reached — the
246
+ * dependency is optional iff that block is a `try` block. Reimplemented rather
247
+ * than imported because metro is not a Bloom dependency (it arrives through
248
+ * react-native), and a gate must not rest on a package nothing declares.
249
+ *
250
+ * `TSAsExpression` and friends are transparent here for the same reason they are
251
+ * under Metro: they are gone by the time Metro sees the file.
252
+ */
253
+ function isMetroOptional(node: ts.Node): boolean {
254
+ let statementsCrossed = 0;
255
+ let current: ts.Node | undefined = node;
256
+
257
+ while (current && statementsCrossed < 3) {
258
+ if (ts.isBlock(current)) {
259
+ return current.parent !== undefined && ts.isTryStatement(current.parent) && current.parent.tryBlock === current;
260
+ }
261
+ if (ts.isStatement(current)) statementsCrossed += 1;
262
+ current = current.parent;
263
+ }
264
+
265
+ return false;
266
+ }
267
+
268
+ /** Every `require(...)` call in a file. `specifier` is null when non-literal. */
269
+ function requireCalls(source: ts.SourceFile): {
270
+ specifier: string | null;
271
+ metroOptional: boolean;
272
+ line: number;
273
+ }[] {
274
+ const found: { specifier: string | null; metroOptional: boolean; line: number }[] = [];
275
+
276
+ const visit = (node: ts.Node): void => {
277
+ if (
278
+ ts.isCallExpression(node) &&
279
+ ts.isIdentifier(node.expression) &&
280
+ node.expression.text === 'require' &&
281
+ node.arguments.length === 1
282
+ ) {
283
+ const [argument] = node.arguments;
284
+ found.push({
285
+ specifier: argument && ts.isStringLiteral(argument) ? argument.text : null,
286
+ metroOptional: isMetroOptional(node),
287
+ line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1,
288
+ });
289
+ }
290
+ ts.forEachChild(node, visit);
291
+ };
292
+
293
+ visit(source);
294
+ return found;
295
+ }
296
+
297
+ /** Source with comments stripped — a scanner that flags its own prose is one nobody keeps. */
298
+ function codeWithoutComments(file: string): string {
299
+ return readFileSync(file, 'utf8')
300
+ .replace(/\/\*[\s\S]*?\*\//g, '')
301
+ .replace(/\/\/[^\n]*/g, '');
302
+ }
303
+
304
+ describe('optional peers are loaded through Metro’s optional-dependency form', () => {
305
+ const files = sourceFiles(SRC);
306
+
307
+ /** Every (file, optional peer) pair the tree actually loads via `require`. */
308
+ function foundBoundaries(): {
309
+ peer: string;
310
+ file: string;
311
+ line: number;
312
+ metroOptional: boolean;
313
+ }[] {
314
+ const found: { peer: string; file: string; line: number; metroOptional: boolean }[] = [];
315
+
316
+ for (const file of files) {
317
+ for (const { specifier, line, metroOptional } of requireCalls(parse(file))) {
318
+ if (specifier === null) continue;
319
+ const peer = OPTIONAL_PEERS.find((candidate) => specifierTargets(specifier, candidate));
320
+ if (!peer) continue;
321
+ found.push({ peer, file: file.slice(SRC.length + 1), line, metroOptional });
322
+ }
323
+ }
324
+
325
+ return found;
326
+ }
327
+
328
+ it('places every boundary require where Metro will mark it optional', () => {
329
+ // The narrow rule from the header, not the loose one: a literal specifier
330
+ // inside a try is NOT enough — nested one `if` deeper it is collected as a
331
+ // hard dependency, and an app without the peer stops building.
332
+ const notOptional = foundBoundaries()
333
+ .filter((entry) => !entry.metroOptional)
334
+ .map(
335
+ (entry) =>
336
+ `${entry.file}:${entry.line} requires '${entry.peer}' where Metro will NOT mark it ` +
337
+ 'optional — the call must be a DIRECT statement of a `try` block (hoist any ' +
338
+ '`typeof require` guard out of the try); an absent peer otherwise fails the build',
339
+ );
340
+
341
+ expect(notOptional).toEqual([]);
342
+ });
343
+
344
+ it('loads each optional peer from exactly the documented boundary file', () => {
345
+ // Both directions in one assertion: a boundary that moved (or was deleted)
346
+ // leaves a stale table entry; a second load site for the same peer, or a new
347
+ // peer loaded from nowhere documented, shows up as an extra pair.
348
+ // Deduplicated: the claim is which FILE owns which peer, not how many calls
349
+ // it makes (adaptive-colors reads expo-router once per platform branch).
350
+ const found = [
351
+ ...new Set(foundBoundaries().map(({ peer, file }) => `${peer} <- ${file}`)),
352
+ ].sort();
353
+ const documented = DYNAMIC_BOUNDARIES.map(({ peer, file }) => `${peer} <- ${file}`).sort();
354
+
355
+ expect(found).toEqual(documented);
356
+ });
357
+ });
358
+
359
+ describe('no require() resolves through a variable specifier', () => {
360
+ const files = sourceFiles(SRC);
361
+
362
+ it('never calls require() with a non-literal specifier outside the allowlist', () => {
363
+ const offenders: string[] = [];
364
+
365
+ for (const file of files) {
366
+ const relative = file.slice(SRC.length + 1);
367
+ if (VARIABLE_REQUIRE_ALLOWED.some((entry) => entry.file === relative)) continue;
368
+ for (const { specifier, line } of requireCalls(parse(file))) {
369
+ if (specifier !== null) continue;
370
+ offenders.push(
371
+ `${relative}:${line} require() with a non-literal specifier. Metro collects nothing ` +
372
+ 'and emits a thrower for any specifier it cannot evaluate, so such a call resolves ' +
373
+ 'nothing on a device whether or not the package is installed. Make it a literal ' +
374
+ 'inside a `try` (see connection-status/netinfo.ts) or a plain static import for a ' +
375
+ 'required peer',
376
+ );
377
+ }
378
+ }
379
+
380
+ expect(offenders).toEqual([]);
381
+ });
382
+
383
+ it('keeps no stale variable-require allowlist entries', () => {
384
+ const stale = VARIABLE_REQUIRE_ALLOWED.filter(
385
+ (entry) => !requireCalls(parse(join(SRC, entry.file))).some(({ specifier }) => specifier === null),
386
+ ).map((entry) => `${entry.file} no longer calls require() with a variable — drop the entry`);
387
+
388
+ expect(stale).toEqual([]);
389
+ });
390
+
391
+ // The deleted `lazyRequire` helper is the reason this class of defect shipped
392
+ // at all: one import made a dead load look like a supported pattern, and four
393
+ // call sites took it. The file and any reference to it are asserted
394
+ // SEPARATELY so neither can hide behind the other's failure.
395
+ it('has deleted the lazyRequire helper file', () => {
396
+ expect(existsSync(join(SRC, 'utils', 'lazy-require.ts'))).toBe(false);
397
+ });
398
+
399
+ it('has no lazyRequire reference left anywhere in the tree', () => {
400
+ const referencing = files
401
+ .filter((file) => /\blazyRequire\b/.test(codeWithoutComments(file)))
402
+ .map((file) => file.slice(SRC.length + 1));
403
+
404
+ expect(referencing).toEqual([]);
405
+ });
406
+ });
@@ -0,0 +1,75 @@
1
+ import React from 'react';
2
+ import { render } from '@testing-library/react-native';
3
+
4
+ import { Avatar } from '../avatar';
5
+ import { AnimatedCheck } from '../animated-check';
6
+ import { SpinnerIcon } from '../loading/SpinnerIcon';
7
+ import { BloomThemeProvider } from '../theme/BloomThemeProvider';
8
+
9
+ /**
10
+ * `react-native-svg` is a REQUIRED peer, so every surface that draws with it
11
+ * draws with it — there is no "SVG missing" state to fall back from, and the
12
+ * three fallbacks that used to exist (a circle instead of the squircle clip, an
13
+ * `ActivityIndicator` instead of the spinner, a Unicode `✓` instead of the
14
+ * draw-on check) were unreachable-by-design branches that a broken lazy loader
15
+ * turned into the ONLY reachable branches on every device.
16
+ *
17
+ * These cases pin the SVG output as the one rendered result. They are honest
18
+ * about their limits: they would have passed before the fix too, because jest
19
+ * resolves a dynamic `require` that Metro cannot. Their job is to stop a
20
+ * fallback branch being reintroduced; the gate against the load shape itself is
21
+ * the static scan in `optional-peer-imports.test.ts`.
22
+ */
23
+
24
+ /** Every host-element type in a rendered tree, in document order. */
25
+ function elementTypes(node: unknown, out: string[] = []): string[] {
26
+ if (Array.isArray(node)) {
27
+ for (const child of node) elementTypes(child, out);
28
+ return out;
29
+ }
30
+ if (!node || typeof node !== 'object') return out;
31
+ const element = node as { type?: unknown; children?: unknown };
32
+ if (typeof element.type === 'string') out.push(element.type);
33
+ if (element.children) elementTypes(element.children, out);
34
+ return out;
35
+ }
36
+
37
+ function renderWithTheme(ui: React.ReactElement) {
38
+ return render(
39
+ <BloomThemeProvider mode="light" colorPreset="teal" fonts={false}>
40
+ {ui}
41
+ </BloomThemeProvider>,
42
+ );
43
+ }
44
+
45
+ describe('surfaces backed by the required react-native-svg peer', () => {
46
+ it('clips a squircle avatar with an SVG path, never the circle fallback', () => {
47
+ const { toJSON } = renderWithTheme(
48
+ <Avatar shape="squircle" uri="https://cloud.oxy.so/avatar.png" size={48} />,
49
+ );
50
+ const types = elementTypes(toJSON());
51
+
52
+ // The clip is the whole point of the shape: `Svg > Defs > ClipPath > Path`
53
+ // feeding a clipped `<Image href>`.
54
+ expect(types).toEqual(expect.arrayContaining(['Svg', 'Defs', 'ClipPath', 'Path', 'SvgImage']));
55
+ });
56
+
57
+ it('draws the spinner blades as SVG rects, never an ActivityIndicator', () => {
58
+ const { toJSON } = render(<SpinnerIcon size={26} color="#111111" />);
59
+ const types = elementTypes(toJSON());
60
+
61
+ expect(types).toContain('Svg');
62
+ // Eight blades with the opacity trail — a count, so a partially rendered
63
+ // spinner cannot pass as a whole one.
64
+ expect(types.filter((type) => type === 'Rect')).toHaveLength(8);
65
+ expect(types).not.toContain('ActivityIndicator');
66
+ });
67
+
68
+ it('draws the animated check as an SVG ring and path, never a Unicode glyph', () => {
69
+ const { toJSON } = renderWithTheme(<AnimatedCheck size={48} />);
70
+ const types = elementTypes(toJSON());
71
+
72
+ expect(types).toEqual(expect.arrayContaining(['Svg', 'Circle', 'Path']));
73
+ expect(types).not.toContain('Text');
74
+ });
75
+ });