@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.
Files changed (134) 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/commonjs/toast/Toaster.js +5 -0
  26. package/lib/commonjs/toast/Toaster.js.map +1 -1
  27. package/lib/commonjs/toast/use-single-outlet-guard.js +72 -0
  28. package/lib/commonjs/toast/use-single-outlet-guard.js.map +1 -0
  29. package/lib/module/animated-check/AnimatedCheck.js +22 -91
  30. package/lib/module/animated-check/AnimatedCheck.js.map +1 -1
  31. package/lib/module/avatar/Avatar.js +1 -19
  32. package/lib/module/avatar/Avatar.js.map +1 -1
  33. package/lib/module/avatar/AvatarRing.js +7 -23
  34. package/lib/module/avatar/AvatarRing.js.map +1 -1
  35. package/lib/module/bottom-sheet/index.js +9 -4
  36. package/lib/module/bottom-sheet/index.js.map +1 -1
  37. package/lib/module/connection-status/index.js +16 -6
  38. package/lib/module/connection-status/index.js.map +1 -1
  39. package/lib/module/connection-status/netinfo.js +114 -0
  40. package/lib/module/connection-status/netinfo.js.map +1 -0
  41. package/lib/module/hooks/haptics-module.js +104 -0
  42. package/lib/module/hooks/haptics-module.js.map +1 -0
  43. package/lib/module/hooks/useHaptics.js +7 -11
  44. package/lib/module/hooks/useHaptics.js.map +1 -1
  45. package/lib/module/loading/SpinnerIcon.js +13 -46
  46. package/lib/module/loading/SpinnerIcon.js.map +1 -1
  47. package/lib/module/loading/index.web.js +2 -2
  48. package/lib/module/theme/adaptive-colors.js +20 -4
  49. package/lib/module/theme/adaptive-colors.js.map +1 -1
  50. package/lib/module/theme/color-scope/style-builder.js +107 -6
  51. package/lib/module/theme/color-scope/style-builder.js.map +1 -1
  52. package/lib/module/theme/init-css-interop.js +13 -11
  53. package/lib/module/theme/init-css-interop.js.map +1 -1
  54. package/lib/module/toast/Toaster.js +5 -0
  55. package/lib/module/toast/Toaster.js.map +1 -1
  56. package/lib/module/toast/use-single-outlet-guard.js +65 -0
  57. package/lib/module/toast/use-single-outlet-guard.js.map +1 -0
  58. package/lib/typescript/commonjs/animated-check/AnimatedCheck.d.ts.map +1 -1
  59. package/lib/typescript/commonjs/avatar/Avatar.d.ts.map +1 -1
  60. package/lib/typescript/commonjs/avatar/AvatarRing.d.ts.map +1 -1
  61. package/lib/typescript/commonjs/bottom-sheet/index.d.ts.map +1 -1
  62. package/lib/typescript/commonjs/connection-status/index.d.ts.map +1 -1
  63. package/lib/typescript/commonjs/connection-status/netinfo.d.ts +69 -0
  64. package/lib/typescript/commonjs/connection-status/netinfo.d.ts.map +1 -0
  65. package/lib/typescript/commonjs/hooks/haptics-module.d.ts +58 -0
  66. package/lib/typescript/commonjs/hooks/haptics-module.d.ts.map +1 -0
  67. package/lib/typescript/commonjs/hooks/useHaptics.d.ts.map +1 -1
  68. package/lib/typescript/commonjs/loading/SpinnerIcon.d.ts +9 -2
  69. package/lib/typescript/commonjs/loading/SpinnerIcon.d.ts.map +1 -1
  70. package/lib/typescript/commonjs/theme/adaptive-colors.d.ts.map +1 -1
  71. package/lib/typescript/commonjs/theme/color-scope/style-builder.d.ts.map +1 -1
  72. package/lib/typescript/commonjs/theme/init-css-interop.d.ts.map +1 -1
  73. package/lib/typescript/commonjs/toast/Toaster.d.ts.map +1 -1
  74. package/lib/typescript/commonjs/toast/use-single-outlet-guard.d.ts +9 -0
  75. package/lib/typescript/commonjs/toast/use-single-outlet-guard.d.ts.map +1 -0
  76. package/lib/typescript/module/animated-check/AnimatedCheck.d.ts.map +1 -1
  77. package/lib/typescript/module/avatar/Avatar.d.ts.map +1 -1
  78. package/lib/typescript/module/avatar/AvatarRing.d.ts.map +1 -1
  79. package/lib/typescript/module/bottom-sheet/index.d.ts.map +1 -1
  80. package/lib/typescript/module/connection-status/index.d.ts.map +1 -1
  81. package/lib/typescript/module/connection-status/netinfo.d.ts +69 -0
  82. package/lib/typescript/module/connection-status/netinfo.d.ts.map +1 -0
  83. package/lib/typescript/module/hooks/haptics-module.d.ts +58 -0
  84. package/lib/typescript/module/hooks/haptics-module.d.ts.map +1 -0
  85. package/lib/typescript/module/hooks/useHaptics.d.ts.map +1 -1
  86. package/lib/typescript/module/loading/SpinnerIcon.d.ts +9 -2
  87. package/lib/typescript/module/loading/SpinnerIcon.d.ts.map +1 -1
  88. package/lib/typescript/module/theme/adaptive-colors.d.ts.map +1 -1
  89. package/lib/typescript/module/theme/color-scope/style-builder.d.ts.map +1 -1
  90. package/lib/typescript/module/theme/init-css-interop.d.ts.map +1 -1
  91. package/lib/typescript/module/toast/Toaster.d.ts.map +1 -1
  92. package/lib/typescript/module/toast/use-single-outlet-guard.d.ts +9 -0
  93. package/lib/typescript/module/toast/use-single-outlet-guard.d.ts.map +1 -0
  94. package/package.json +348 -88
  95. package/src/__tests__/color-scope-nativewind.test.ts +135 -0
  96. package/src/__tests__/connection-status-netinfo.test.tsx +203 -0
  97. package/src/__tests__/exports-map-contract.test.ts +112 -0
  98. package/src/__tests__/optional-peer-imports.test.ts +406 -0
  99. package/src/__tests__/svg-required-peer.test.tsx +75 -0
  100. package/src/__tests__/toast-single-outlet-guard.test.tsx +176 -0
  101. package/src/__tests__/useHaptics.test.tsx +203 -16
  102. package/src/animated-check/AnimatedCheck.tsx +19 -86
  103. package/src/avatar/Avatar.tsx +1 -11
  104. package/src/avatar/AvatarRing.tsx +7 -15
  105. package/src/bottom-sheet/index.tsx +9 -4
  106. package/src/connection-status/index.tsx +16 -6
  107. package/src/connection-status/netinfo.ts +131 -0
  108. package/src/hooks/haptics-module.ts +118 -0
  109. package/src/hooks/useHaptics.tsx +6 -9
  110. package/src/loading/SpinnerIcon.tsx +13 -52
  111. package/src/loading/index.web.ts +2 -2
  112. package/src/theme/adaptive-colors.ts +20 -4
  113. package/src/theme/color-scope/style-builder.ts +118 -6
  114. package/src/theme/init-css-interop.ts +13 -11
  115. package/src/toast/Toaster.tsx +6 -0
  116. package/src/toast/use-single-outlet-guard.ts +72 -0
  117. package/lib/commonjs/avatar/svg-module.js +0 -15
  118. package/lib/commonjs/avatar/svg-module.js.map +0 -1
  119. package/lib/commonjs/utils/lazy-require.js +0 -37
  120. package/lib/commonjs/utils/lazy-require.js.map +0 -1
  121. package/lib/module/avatar/svg-module.js +0 -11
  122. package/lib/module/avatar/svg-module.js.map +0 -1
  123. package/lib/module/utils/lazy-require.js +0 -33
  124. package/lib/module/utils/lazy-require.js.map +0 -1
  125. package/lib/typescript/commonjs/avatar/svg-module.d.ts +0 -8
  126. package/lib/typescript/commonjs/avatar/svg-module.d.ts.map +0 -1
  127. package/lib/typescript/commonjs/utils/lazy-require.d.ts +0 -15
  128. package/lib/typescript/commonjs/utils/lazy-require.d.ts.map +0 -1
  129. package/lib/typescript/module/avatar/svg-module.d.ts +0 -8
  130. package/lib/typescript/module/avatar/svg-module.d.ts.map +0 -1
  131. package/lib/typescript/module/utils/lazy-require.d.ts +0 -15
  132. package/lib/typescript/module/utils/lazy-require.d.ts.map +0 -1
  133. package/src/avatar/svg-module.ts +0 -11
  134. 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
+ });