@oxyhq/bloom 0.67.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/commonjs/toast/Toaster.js +5 -0
- package/lib/commonjs/toast/Toaster.js.map +1 -1
- package/lib/commonjs/toast/use-single-outlet-guard.js +72 -0
- package/lib/commonjs/toast/use-single-outlet-guard.js.map +1 -0
- 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/module/toast/Toaster.js +5 -0
- package/lib/module/toast/Toaster.js.map +1 -1
- package/lib/module/toast/use-single-outlet-guard.js +65 -0
- package/lib/module/toast/use-single-outlet-guard.js.map +1 -0
- 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/commonjs/toast/Toaster.d.ts.map +1 -1
- package/lib/typescript/commonjs/toast/use-single-outlet-guard.d.ts +9 -0
- package/lib/typescript/commonjs/toast/use-single-outlet-guard.d.ts.map +1 -0
- 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/lib/typescript/module/toast/Toaster.d.ts.map +1 -1
- package/lib/typescript/module/toast/use-single-outlet-guard.d.ts +9 -0
- package/lib/typescript/module/toast/use-single-outlet-guard.d.ts.map +1 -0
- package/package.json +348 -88
- package/src/__tests__/color-scope-nativewind.test.ts +135 -0
- package/src/__tests__/connection-status-netinfo.test.tsx +203 -0
- package/src/__tests__/exports-map-contract.test.ts +112 -0
- package/src/__tests__/optional-peer-imports.test.ts +406 -0
- package/src/__tests__/svg-required-peer.test.tsx +75 -0
- package/src/__tests__/toast-single-outlet-guard.test.tsx +176 -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/src/toast/Toaster.tsx +6 -0
- package/src/toast/use-single-outlet-guard.ts +72 -0
- 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,135 @@
|
|
|
1
|
+
import * as ReactNative from 'react-native';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* THE OPTIONAL-PEER BOUNDARY for `nativewind`.
|
|
5
|
+
*
|
|
6
|
+
* `BloomColorScope` publishes a preset's CSS vars into the subtree through the
|
|
7
|
+
* CONSUMER's nativewind — its version decides which mechanism exists
|
|
8
|
+
* (`VariableContextProvider` under NativeWind 5, `vars()` before it), so the
|
|
9
|
+
* module has to be loaded from the app, not from Bloom's own react-native-css.
|
|
10
|
+
* Loaded through a variable specifier it was never loaded at all under Metro,
|
|
11
|
+
* and a scope that does not scope is invisible: the subtree keeps rendering,
|
|
12
|
+
* just in the app-wide palette.
|
|
13
|
+
*
|
|
14
|
+
* `optional-peer-imports.test.ts` guards the static shape (literal specifier,
|
|
15
|
+
* inside a try). This runs it: the module is used when present, the scope
|
|
16
|
+
* degrades rather than crashes when absent, and it says so once.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const NATIVEWIND = 'nativewind';
|
|
20
|
+
|
|
21
|
+
type StyleBuilder = typeof import('../theme/color-scope/style-builder');
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Load `style-builder` against a given nativewind factory.
|
|
25
|
+
*
|
|
26
|
+
* `{ virtual: true }` IS correct here, unlike in the netinfo suite: nativewind
|
|
27
|
+
* is not installed in this repo at all, so without it `doMock` throws while
|
|
28
|
+
* resolving the name it is being asked to stand in for. The trade is that a
|
|
29
|
+
* virtual mock is keyed by the bare specifier — which is exactly what the
|
|
30
|
+
* loader requires, so the two agree.
|
|
31
|
+
*/
|
|
32
|
+
function loadStyleBuilder(factory?: () => unknown): StyleBuilder {
|
|
33
|
+
let mod: StyleBuilder | undefined;
|
|
34
|
+
|
|
35
|
+
jest.isolateModules(() => {
|
|
36
|
+
if (factory) jest.doMock(NATIVEWIND, factory, { virtual: true });
|
|
37
|
+
// A second copy of react-native carries a second `Platform`, so the OS each
|
|
38
|
+
// case sets would land on an object the module under test never reads.
|
|
39
|
+
jest.doMock('react-native', () => ReactNative);
|
|
40
|
+
|
|
41
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
42
|
+
mod = require('../theme/color-scope/style-builder') as StyleBuilder;
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
if (!mod) throw new Error('module isolation did not run');
|
|
46
|
+
return mod;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const originalOS = ReactNative.Platform.OS;
|
|
50
|
+
|
|
51
|
+
describe('BloomColorScope nativewind loading', () => {
|
|
52
|
+
let warn: jest.SpyInstance<void, Parameters<typeof console.warn>>;
|
|
53
|
+
|
|
54
|
+
beforeEach(() => {
|
|
55
|
+
warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
56
|
+
ReactNative.Platform.OS = 'ios';
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
ReactNative.Platform.OS = originalOS;
|
|
61
|
+
warn.mockRestore();
|
|
62
|
+
jest.resetModules();
|
|
63
|
+
jest.dontMock(NATIVEWIND);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('uses the consumer’s VariableContextProvider when nativewind provides one', () => {
|
|
67
|
+
const VariableContextProvider = () => null;
|
|
68
|
+
const vars = jest.fn(() => ({ marker: true }));
|
|
69
|
+
const builder = loadStyleBuilder(() => ({ vars, VariableContextProvider }));
|
|
70
|
+
|
|
71
|
+
expect(builder.getVariableContextProvider()).toBe(VariableContextProvider);
|
|
72
|
+
expect(warn).not.toHaveBeenCalled();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('passes the resolved scope vars through the consumer’s vars()', () => {
|
|
76
|
+
const vars = jest.fn((_record: Record<string, string>) => ({ marker: true }));
|
|
77
|
+
const builder = loadStyleBuilder(() => ({ vars }));
|
|
78
|
+
|
|
79
|
+
expect(builder.buildNativePresetStyle('blue', 'dark')).toEqual({ marker: true });
|
|
80
|
+
// Not just "called": the record it forwards must be the canonical tokens
|
|
81
|
+
// plus the Tailwind `--color-*` aliases, which is the whole payload of a
|
|
82
|
+
// scope. A `vars({})` would otherwise read as a working scope.
|
|
83
|
+
const [record] = vars.mock.calls[0] ?? [{}];
|
|
84
|
+
expect(record['--primary']).toMatch(/^rgb/);
|
|
85
|
+
expect(record['--color-primary']).toBe(record['--primary']);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('degrades to no scope, and says so once, when the peer is absent', () => {
|
|
89
|
+
const builder = loadStyleBuilder(() => {
|
|
90
|
+
throw new Error(`Cannot find module '${NATIVEWIND}'`);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(builder.getVariableContextProvider()).toBeNull();
|
|
94
|
+
expect(builder.buildNativePresetStyle('blue', 'light')).toBeUndefined();
|
|
95
|
+
|
|
96
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
97
|
+
const [message] = warn.mock.calls[0] as [string];
|
|
98
|
+
expect(message).toContain('BloomColorScope');
|
|
99
|
+
expect(message).toContain(NATIVEWIND);
|
|
100
|
+
expect(message).toContain('expo install');
|
|
101
|
+
expect(message).toContain('Cannot find module');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('names the NativeWind 5 API when nativewind resolves without it', () => {
|
|
105
|
+
const builder = loadStyleBuilder(() => ({ vars: jest.fn(() => ({})) }));
|
|
106
|
+
|
|
107
|
+
expect(builder.getVariableContextProvider()).toBeNull();
|
|
108
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
109
|
+
expect((warn.mock.calls[0] as [string])[0]).toContain('VariableContextProvider');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('never loads nativewind on web, where the vars go on documentElement', () => {
|
|
113
|
+
ReactNative.Platform.OS = 'web';
|
|
114
|
+
const vars = jest.fn(() => ({ marker: true }));
|
|
115
|
+
const builder = loadStyleBuilder(() => ({ vars, VariableContextProvider: () => null }));
|
|
116
|
+
|
|
117
|
+
expect(builder.getVariableContextProvider()).toBeNull();
|
|
118
|
+
expect(builder.buildNativePresetStyle('blue', 'light')).toBeUndefined();
|
|
119
|
+
expect(vars).not.toHaveBeenCalled();
|
|
120
|
+
// A warning here would fire on every web app rendering a scoped subtree.
|
|
121
|
+
expect(warn).not.toHaveBeenCalled();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('builds the same token map on every platform', () => {
|
|
125
|
+
// `buildScopeVars` is the pure half — it must not depend on nativewind at
|
|
126
|
+
// all, since web consumes it directly as an inline style object.
|
|
127
|
+
const builder = loadStyleBuilder(() => {
|
|
128
|
+
throw new Error('absent');
|
|
129
|
+
});
|
|
130
|
+
const scope = builder.buildScopeVars('green', 'light');
|
|
131
|
+
|
|
132
|
+
expect(scope['--primary']).toMatch(/^rgb/);
|
|
133
|
+
expect(scope['--color-primary']).toBe(scope['--primary']);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render } from '@testing-library/react-native';
|
|
3
|
+
import type NetInfo from '@react-native-community/netinfo';
|
|
4
|
+
|
|
5
|
+
import type { NetInfoLike } from '../connection-status/netinfo';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* THE OPTIONAL-PEER BOUNDARY for `@react-native-community/netinfo`.
|
|
9
|
+
*
|
|
10
|
+
* The defect: netinfo was declared an optional peer AND imported statically, so
|
|
11
|
+
* an app that never installed it did not lose the outage toast — its native
|
|
12
|
+
* bundle failed to build, pointing at a package it never mentions. The fix
|
|
13
|
+
* (`try { require('<literal>') } catch`) has to hold two properties at once, and
|
|
14
|
+
* both are asserted here: the real module is still used when it IS installed,
|
|
15
|
+
* and the component degrades to inert — not to a crash, and not silently —
|
|
16
|
+
* when it is not.
|
|
17
|
+
*
|
|
18
|
+
* `optional-peer-imports.test.ts` guards the static shape of the same contract.
|
|
19
|
+
* This one runs it.
|
|
20
|
+
*
|
|
21
|
+
* Every case goes through `jest.isolateModules` + `jest.doMock` because the
|
|
22
|
+
* loader caches its result in module state: without a fresh registry the first
|
|
23
|
+
* case to resolve netinfo would leave every later case asserting against a cache
|
|
24
|
+
* it can no longer influence, passing whatever the implementation did.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const NETINFO = '@react-native-community/netinfo';
|
|
28
|
+
const reactModule: unknown = jest.requireActual('react');
|
|
29
|
+
|
|
30
|
+
/** A netinfo state object with only the two fields the component reads. */
|
|
31
|
+
function state(isConnected: boolean, isInternetReachable: boolean | null = true) {
|
|
32
|
+
return { isConnected, isInternetReachable };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Compile-time half of the contract: the real netinfo surface must remain
|
|
37
|
+
* assignable to Bloom's hand-written `NetInfoLike`. The interface is written by
|
|
38
|
+
* hand so netinfo's types never reach Bloom's emitted `.d.ts` (a consumer that
|
|
39
|
+
* skips the optional peer must not inherit a TS7016 from Bloom), which leaves
|
|
40
|
+
* this as the only place a shape change in netinfo can be caught.
|
|
41
|
+
*
|
|
42
|
+
* It is enforced by `bun run typescript`, NOT by jest: ts-jest transpiles
|
|
43
|
+
* without typechecking here, so a drifted type is green under `bunx jest` and
|
|
44
|
+
* red under `tsc` (verified by mutation — widening the unsubscribe to
|
|
45
|
+
* `Promise<void>` produces `TS2322: Type 'true' is not assignable to type
|
|
46
|
+
* 'never'` on the line below).
|
|
47
|
+
*/
|
|
48
|
+
type RealNetInfoIsCompatible = typeof NetInfo extends NetInfoLike ? true : never;
|
|
49
|
+
const NETINFO_TYPES_STILL_MATCH: RealNetInfoIsCompatible = true;
|
|
50
|
+
|
|
51
|
+
type Harness = {
|
|
52
|
+
ConnectionStatusToasts: React.ComponentType<Record<string, never>>;
|
|
53
|
+
toast: { error: jest.Mock; warning: jest.Mock; success: jest.Mock };
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Load `connection-status` against a given netinfo module factory. Passing a
|
|
58
|
+
* factory that THROWS is how the absent-peer case is expressed: that is exactly
|
|
59
|
+
* what the `catch` sees when Metro stubs an unresolved optional dependency.
|
|
60
|
+
*/
|
|
61
|
+
function loadWithNetInfo(factory: () => unknown): Harness {
|
|
62
|
+
let harness: Harness | undefined;
|
|
63
|
+
|
|
64
|
+
jest.isolateModules(() => {
|
|
65
|
+
const toast = { error: jest.fn(), warning: jest.fn(), success: jest.fn() };
|
|
66
|
+
// NOT `{ virtual: true }`: netinfo is a real devDependency here, and a
|
|
67
|
+
// virtual mock is keyed by the bare name while the component's `require`
|
|
68
|
+
// resolves to the installed path — the mock would never be consulted and
|
|
69
|
+
// every case would quietly run against the real module.
|
|
70
|
+
jest.doMock(NETINFO, factory);
|
|
71
|
+
jest.doMock('../toast', () => ({ toast }));
|
|
72
|
+
// An isolated registry would otherwise build the component against a SECOND
|
|
73
|
+
// copy of React, whose dispatcher is null under the outer renderer — every
|
|
74
|
+
// case would fail on `useState` before reaching what it asserts. Pinning
|
|
75
|
+
// react to the outer instance keeps one hook dispatcher for the whole tree.
|
|
76
|
+
jest.doMock('react', () => reactModule);
|
|
77
|
+
|
|
78
|
+
// A plain `require`, NOT `jest.requireActual`: the latter bypasses the mock
|
|
79
|
+
// registry for the whole dependency subtree, so the doMocks above would be
|
|
80
|
+
// ignored and the suite would silently exercise the REAL netinfo.
|
|
81
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
82
|
+
const { ConnectionStatusToasts } = require('../connection-status') as typeof import('../connection-status');
|
|
83
|
+
|
|
84
|
+
harness = { ConnectionStatusToasts, toast };
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
if (!harness) throw new Error('module isolation did not run');
|
|
88
|
+
return harness;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
describe('ConnectionStatusToasts netinfo loading', () => {
|
|
92
|
+
let warn: jest.SpyInstance<void, Parameters<typeof console.warn>>;
|
|
93
|
+
|
|
94
|
+
beforeEach(() => {
|
|
95
|
+
// `__mocks__/setup.ts` already silences console.warn; this re-spies so the
|
|
96
|
+
// calls are recorded and stay scoped to one test.
|
|
97
|
+
warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
afterEach(() => {
|
|
101
|
+
warn.mockRestore();
|
|
102
|
+
jest.resetModules();
|
|
103
|
+
jest.dontMock(NETINFO);
|
|
104
|
+
jest.dontMock('../toast');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('keeps its own types in step with the real netinfo surface', () => {
|
|
108
|
+
// The real assertion is the type annotation above, checked by `tsc`. This
|
|
109
|
+
// only keeps the constant referenced so it cannot be dropped as unused.
|
|
110
|
+
expect(NETINFO_TYPES_STILL_MATCH).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('subscribes through the named export when the peer is installed', () => {
|
|
114
|
+
const unsubscribe = jest.fn();
|
|
115
|
+
const addEventListener = jest.fn().mockReturnValue(unsubscribe);
|
|
116
|
+
const { ConnectionStatusToasts, toast } = loadWithNetInfo(() => ({ addEventListener }));
|
|
117
|
+
|
|
118
|
+
const view = render(<ConnectionStatusToasts />);
|
|
119
|
+
expect(addEventListener).toHaveBeenCalledTimes(1);
|
|
120
|
+
expect(warn).not.toHaveBeenCalled();
|
|
121
|
+
|
|
122
|
+
const emit = addEventListener.mock.calls[0][0] as (s: ReturnType<typeof state>) => void;
|
|
123
|
+
view.rerender(<ConnectionStatusToasts />);
|
|
124
|
+
emit(state(false));
|
|
125
|
+
view.rerender(<ConnectionStatusToasts />);
|
|
126
|
+
|
|
127
|
+
expect(toast.error).toHaveBeenCalledWith('No internet connection', expect.anything());
|
|
128
|
+
|
|
129
|
+
view.unmount();
|
|
130
|
+
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('subscribes through the default export too', () => {
|
|
134
|
+
// Which shape comes back depends on whether the bundler resolved netinfo's
|
|
135
|
+
// CommonJS build or its ESM/source entry; both are live in the fleet.
|
|
136
|
+
const addEventListener = jest.fn().mockReturnValue(jest.fn());
|
|
137
|
+
const { ConnectionStatusToasts } = loadWithNetInfo(() => ({
|
|
138
|
+
__esModule: true,
|
|
139
|
+
default: { addEventListener },
|
|
140
|
+
}));
|
|
141
|
+
|
|
142
|
+
render(<ConnectionStatusToasts />);
|
|
143
|
+
expect(addEventListener).toHaveBeenCalledTimes(1);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('renders inert instead of throwing when the peer is absent', () => {
|
|
147
|
+
const { ConnectionStatusToasts, toast } = loadWithNetInfo(() => {
|
|
148
|
+
throw new Error(`Cannot find module '${NETINFO}'`);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const view = render(<ConnectionStatusToasts />);
|
|
152
|
+
expect(view.toJSON()).toBeNull();
|
|
153
|
+
expect(toast.error).not.toHaveBeenCalled();
|
|
154
|
+
expect(toast.warning).not.toHaveBeenCalled();
|
|
155
|
+
expect(toast.success).not.toHaveBeenCalled();
|
|
156
|
+
expect(() => view.unmount()).not.toThrow();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('names the missing package once, not once per mount', () => {
|
|
160
|
+
const { ConnectionStatusToasts } = loadWithNetInfo(() => {
|
|
161
|
+
throw new Error(`Cannot find module '${NETINFO}'`);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
render(<ConnectionStatusToasts />).unmount();
|
|
165
|
+
render(<ConnectionStatusToasts />).unmount();
|
|
166
|
+
|
|
167
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
168
|
+
const [message] = warn.mock.calls[0] as [string];
|
|
169
|
+
// An actionable warning has to carry all three: what broke, which package,
|
|
170
|
+
// and how to fix it. A bare "netinfo missing" sends the reader to Bloom.
|
|
171
|
+
expect(message).toContain(NETINFO);
|
|
172
|
+
expect(message).toContain('inert');
|
|
173
|
+
expect(message).toContain('expo install');
|
|
174
|
+
// …and the underlying resolution error, so a shape/version failure is not
|
|
175
|
+
// reported as if the package were simply not installed.
|
|
176
|
+
expect(message).toContain('Cannot find module');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('warns when the peer resolves but has no addEventListener', () => {
|
|
180
|
+
const { ConnectionStatusToasts } = loadWithNetInfo(() => ({ fetch: jest.fn() }));
|
|
181
|
+
|
|
182
|
+
render(<ConnectionStatusToasts />);
|
|
183
|
+
|
|
184
|
+
expect(warn).toHaveBeenCalledTimes(1);
|
|
185
|
+
expect((warn.mock.calls[0] as [string])[0]).toContain('without an `addEventListener` export');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('stays silent in production', () => {
|
|
189
|
+
const previous = process.env.NODE_ENV;
|
|
190
|
+
// Metro and Vite fold this branch away entirely; jest cannot, so the guard
|
|
191
|
+
// is asserted at runtime instead.
|
|
192
|
+
process.env.NODE_ENV = 'production';
|
|
193
|
+
try {
|
|
194
|
+
const { ConnectionStatusToasts } = loadWithNetInfo(() => {
|
|
195
|
+
throw new Error(`Cannot find module '${NETINFO}'`);
|
|
196
|
+
});
|
|
197
|
+
render(<ConnectionStatusToasts />);
|
|
198
|
+
expect(warn).not.toHaveBeenCalled();
|
|
199
|
+
} finally {
|
|
200
|
+
process.env.NODE_ENV = previous;
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The `react-native` export condition must stay SPLIT into `types` + `default`.
|
|
6
|
+
*
|
|
7
|
+
* Metro compiles Bloom from source, so `default` points at `src/`. TypeScript
|
|
8
|
+
* honours `react-native` as well (expo/tsconfig.base sets
|
|
9
|
+
* `customConditions: ["react-native"]`), and if the condition were a bare
|
|
10
|
+
* string every native-targeted consumer would type-check Bloom's own `.tsx`
|
|
11
|
+
* files — pulling `react-dom` (from the web forks) and optional peers like
|
|
12
|
+
* `expo-haptics` / `@react-native-community/netinfo` into a program that never
|
|
13
|
+
* declared them. Measured against a consumer fixture with no
|
|
14
|
+
* `@types/react-dom`, the string form produces TS7016 on
|
|
15
|
+
* `src/portal/index.web.tsx`, TS2307 on `src/hooks/useHaptics.tsx` and
|
|
16
|
+
* `src/connection-status/index.tsx`, plus a nativewind TS2769 on
|
|
17
|
+
* `src/dialog/DialogHeader.tsx`. `skipLibCheck` cannot suppress any of them —
|
|
18
|
+
* a `.tsx` is not a declaration file.
|
|
19
|
+
*
|
|
20
|
+
* Splitting the condition means tsc asks for `types` and gets the built
|
|
21
|
+
* declarations, while Metro (which never requests `types`) still falls through
|
|
22
|
+
* to `default` and bundles source. Verified end to end with a real
|
|
23
|
+
* `expo export --platform ios`.
|
|
24
|
+
*
|
|
25
|
+
* `package.json#exports` is generated by `scripts/generate-platform-exports.mjs`
|
|
26
|
+
* — fix a failure there, never by hand-editing `package.json`.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
type ExportEntry = Record<string, unknown>;
|
|
30
|
+
|
|
31
|
+
// Read rather than `import` the manifest: tsconfig pins `rootDir` to `src`, so
|
|
32
|
+
// a JSON import from outside it would drag package.json into the build program.
|
|
33
|
+
const PKG_PATH = join(__dirname, '..', '..', 'package.json');
|
|
34
|
+
const pkg = JSON.parse(readFileSync(PKG_PATH, 'utf8')) as {
|
|
35
|
+
exports: Record<string, ExportEntry | string>;
|
|
36
|
+
};
|
|
37
|
+
const exportsMap = pkg.exports;
|
|
38
|
+
|
|
39
|
+
/** Non-subpath entries that are legitimately plain strings (static assets). */
|
|
40
|
+
const STRING_ENTRIES = ['./design-tokens/theme.css', './package.json'];
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Vacuity floor. `SUBPATHS` in the generator is 86 entries today; a traversal
|
|
44
|
+
* bug that silently found none must not read as a pass.
|
|
45
|
+
*/
|
|
46
|
+
const MIN_SUBPATHS = 80;
|
|
47
|
+
|
|
48
|
+
const subpathEntries = Object.entries(exportsMap).filter(
|
|
49
|
+
(entry): entry is [string, ExportEntry] => typeof entry[1] !== 'string',
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
describe('package.json#exports — react-native condition', () => {
|
|
53
|
+
it(`declares at least ${MIN_SUBPATHS} conditional subpaths`, () => {
|
|
54
|
+
expect(subpathEntries.length).toBeGreaterThanOrEqual(MIN_SUBPATHS);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('exposes only the known static assets as string exports', () => {
|
|
58
|
+
const strings = Object.entries(exportsMap)
|
|
59
|
+
.filter(([, value]) => typeof value === 'string')
|
|
60
|
+
.map(([name]) => name);
|
|
61
|
+
expect(strings.sort()).toEqual([...STRING_ENTRIES].sort());
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('splits every react-native condition into types + default, in that order', () => {
|
|
65
|
+
const offenders: string[] = [];
|
|
66
|
+
for (const [name, entry] of subpathEntries) {
|
|
67
|
+
const condition = entry['react-native'];
|
|
68
|
+
if (typeof condition !== 'object' || condition === null) {
|
|
69
|
+
offenders.push(`${name}: react-native is ${JSON.stringify(condition)}, expected an object`);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const keys = Object.keys(condition);
|
|
73
|
+
if (keys.join(',') !== 'types,default') {
|
|
74
|
+
offenders.push(`${name}: react-native keys are [${keys.join(', ')}], expected [types, default]`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
expect(offenders).toEqual([]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('points react-native.default at src and react-native.types at the built declarations', () => {
|
|
81
|
+
const offenders: string[] = [];
|
|
82
|
+
for (const [name, entry] of subpathEntries) {
|
|
83
|
+
const condition = entry['react-native'];
|
|
84
|
+
if (typeof condition !== 'object' || condition === null) continue;
|
|
85
|
+
const { types, default: def } = condition as { types?: unknown; default?: unknown };
|
|
86
|
+
|
|
87
|
+
if (typeof def !== 'string' || !def.startsWith('./src/')) {
|
|
88
|
+
offenders.push(`${name}: react-native.default is ${JSON.stringify(def)}, expected a ./src/ path`);
|
|
89
|
+
}
|
|
90
|
+
if (typeof types !== 'string' || !types.startsWith('./lib/typescript/')) {
|
|
91
|
+
offenders.push(
|
|
92
|
+
`${name}: react-native.types is ${JSON.stringify(types)}, expected a ./lib/typescript/ path`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
expect(offenders).toEqual([]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('resolves react-native.types to the same declarations as the import condition', () => {
|
|
100
|
+
const offenders: string[] = [];
|
|
101
|
+
for (const [name, entry] of subpathEntries) {
|
|
102
|
+
const condition = entry['react-native'] as { types?: unknown } | undefined;
|
|
103
|
+
const importCondition = entry.import as { types?: unknown } | undefined;
|
|
104
|
+
if (condition?.types !== importCondition?.types) {
|
|
105
|
+
offenders.push(
|
|
106
|
+
`${name}: react-native.types (${String(condition?.types)}) !== import.types (${String(importCondition?.types)})`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
expect(offenders).toEqual([]);
|
|
111
|
+
});
|
|
112
|
+
});
|