@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.
- package/lib/commonjs/animated-check/AnimatedCheck.js +22 -93
- package/lib/commonjs/animated-check/AnimatedCheck.js.map +1 -1
- package/lib/commonjs/avatar/Avatar.js +6 -24
- package/lib/commonjs/avatar/Avatar.js.map +1 -1
- package/lib/commonjs/avatar/AvatarRing.js +14 -30
- package/lib/commonjs/avatar/AvatarRing.js.map +1 -1
- package/lib/commonjs/bottom-sheet/index.js +9 -4
- package/lib/commonjs/bottom-sheet/index.js.map +1 -1
- package/lib/commonjs/connection-status/index.js +16 -7
- package/lib/commonjs/connection-status/index.js.map +1 -1
- package/lib/commonjs/connection-status/netinfo.js +119 -0
- package/lib/commonjs/connection-status/netinfo.js.map +1 -0
- package/lib/commonjs/hooks/haptics-module.js +109 -0
- package/lib/commonjs/hooks/haptics-module.js.map +1 -0
- package/lib/commonjs/hooks/useHaptics.js +6 -8
- package/lib/commonjs/hooks/useHaptics.js.map +1 -1
- package/lib/commonjs/loading/SpinnerIcon.js +22 -55
- package/lib/commonjs/loading/SpinnerIcon.js.map +1 -1
- package/lib/commonjs/theme/adaptive-colors.js +20 -4
- package/lib/commonjs/theme/adaptive-colors.js.map +1 -1
- package/lib/commonjs/theme/color-scope/style-builder.js +107 -6
- package/lib/commonjs/theme/color-scope/style-builder.js.map +1 -1
- package/lib/commonjs/theme/init-css-interop.js +13 -11
- package/lib/commonjs/theme/init-css-interop.js.map +1 -1
- package/lib/module/animated-check/AnimatedCheck.js +22 -91
- package/lib/module/animated-check/AnimatedCheck.js.map +1 -1
- package/lib/module/avatar/Avatar.js +1 -19
- package/lib/module/avatar/Avatar.js.map +1 -1
- package/lib/module/avatar/AvatarRing.js +7 -23
- package/lib/module/avatar/AvatarRing.js.map +1 -1
- package/lib/module/bottom-sheet/index.js +9 -4
- package/lib/module/bottom-sheet/index.js.map +1 -1
- package/lib/module/connection-status/index.js +16 -6
- package/lib/module/connection-status/index.js.map +1 -1
- package/lib/module/connection-status/netinfo.js +114 -0
- package/lib/module/connection-status/netinfo.js.map +1 -0
- package/lib/module/hooks/haptics-module.js +104 -0
- package/lib/module/hooks/haptics-module.js.map +1 -0
- package/lib/module/hooks/useHaptics.js +7 -11
- package/lib/module/hooks/useHaptics.js.map +1 -1
- package/lib/module/loading/SpinnerIcon.js +13 -46
- package/lib/module/loading/SpinnerIcon.js.map +1 -1
- package/lib/module/loading/index.web.js +2 -2
- package/lib/module/theme/adaptive-colors.js +20 -4
- package/lib/module/theme/adaptive-colors.js.map +1 -1
- package/lib/module/theme/color-scope/style-builder.js +107 -6
- package/lib/module/theme/color-scope/style-builder.js.map +1 -1
- package/lib/module/theme/init-css-interop.js +13 -11
- package/lib/module/theme/init-css-interop.js.map +1 -1
- package/lib/typescript/commonjs/animated-check/AnimatedCheck.d.ts.map +1 -1
- package/lib/typescript/commonjs/avatar/Avatar.d.ts.map +1 -1
- package/lib/typescript/commonjs/avatar/AvatarRing.d.ts.map +1 -1
- package/lib/typescript/commonjs/bottom-sheet/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/connection-status/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/connection-status/netinfo.d.ts +69 -0
- package/lib/typescript/commonjs/connection-status/netinfo.d.ts.map +1 -0
- package/lib/typescript/commonjs/hooks/haptics-module.d.ts +58 -0
- package/lib/typescript/commonjs/hooks/haptics-module.d.ts.map +1 -0
- package/lib/typescript/commonjs/hooks/useHaptics.d.ts.map +1 -1
- package/lib/typescript/commonjs/loading/SpinnerIcon.d.ts +9 -2
- package/lib/typescript/commonjs/loading/SpinnerIcon.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/adaptive-colors.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/color-scope/style-builder.d.ts.map +1 -1
- package/lib/typescript/commonjs/theme/init-css-interop.d.ts.map +1 -1
- package/lib/typescript/module/animated-check/AnimatedCheck.d.ts.map +1 -1
- package/lib/typescript/module/avatar/Avatar.d.ts.map +1 -1
- package/lib/typescript/module/avatar/AvatarRing.d.ts.map +1 -1
- package/lib/typescript/module/bottom-sheet/index.d.ts.map +1 -1
- package/lib/typescript/module/connection-status/index.d.ts.map +1 -1
- package/lib/typescript/module/connection-status/netinfo.d.ts +69 -0
- package/lib/typescript/module/connection-status/netinfo.d.ts.map +1 -0
- package/lib/typescript/module/hooks/haptics-module.d.ts +58 -0
- package/lib/typescript/module/hooks/haptics-module.d.ts.map +1 -0
- package/lib/typescript/module/hooks/useHaptics.d.ts.map +1 -1
- package/lib/typescript/module/loading/SpinnerIcon.d.ts +9 -2
- package/lib/typescript/module/loading/SpinnerIcon.d.ts.map +1 -1
- package/lib/typescript/module/theme/adaptive-colors.d.ts.map +1 -1
- package/lib/typescript/module/theme/color-scope/style-builder.d.ts.map +1 -1
- package/lib/typescript/module/theme/init-css-interop.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/color-scope-nativewind.test.ts +135 -0
- package/src/__tests__/connection-status-netinfo.test.tsx +203 -0
- package/src/__tests__/optional-peer-imports.test.ts +406 -0
- package/src/__tests__/svg-required-peer.test.tsx +75 -0
- package/src/__tests__/useHaptics.test.tsx +203 -16
- package/src/animated-check/AnimatedCheck.tsx +19 -86
- package/src/avatar/Avatar.tsx +1 -11
- package/src/avatar/AvatarRing.tsx +7 -15
- package/src/bottom-sheet/index.tsx +9 -4
- package/src/connection-status/index.tsx +16 -6
- package/src/connection-status/netinfo.ts +131 -0
- package/src/hooks/haptics-module.ts +118 -0
- package/src/hooks/useHaptics.tsx +6 -9
- package/src/loading/SpinnerIcon.tsx +13 -52
- package/src/loading/index.web.ts +2 -2
- package/src/theme/adaptive-colors.ts +20 -4
- package/src/theme/color-scope/style-builder.ts +118 -6
- package/src/theme/init-css-interop.ts +13 -11
- package/lib/commonjs/avatar/svg-module.js +0 -15
- package/lib/commonjs/avatar/svg-module.js.map +0 -1
- package/lib/commonjs/utils/lazy-require.js +0 -37
- package/lib/commonjs/utils/lazy-require.js.map +0 -1
- package/lib/module/avatar/svg-module.js +0 -11
- package/lib/module/avatar/svg-module.js.map +0 -1
- package/lib/module/utils/lazy-require.js +0 -33
- package/lib/module/utils/lazy-require.js.map +0 -1
- package/lib/typescript/commonjs/avatar/svg-module.d.ts +0 -8
- package/lib/typescript/commonjs/avatar/svg-module.d.ts.map +0 -1
- package/lib/typescript/commonjs/utils/lazy-require.d.ts +0 -15
- package/lib/typescript/commonjs/utils/lazy-require.d.ts.map +0 -1
- package/lib/typescript/module/avatar/svg-module.d.ts +0 -8
- package/lib/typescript/module/avatar/svg-module.d.ts.map +0 -1
- package/lib/typescript/module/utils/lazy-require.d.ts +0 -15
- package/lib/typescript/module/utils/lazy-require.d.ts.map +0 -1
- package/src/avatar/svg-module.ts +0 -11
- 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
|
+
});
|