@oxyhq/bloom 0.68.0 → 0.69.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/lib/commonjs/animated-check/AnimatedCheck.js +22 -93
  2. package/lib/commonjs/animated-check/AnimatedCheck.js.map +1 -1
  3. package/lib/commonjs/avatar/Avatar.js +6 -24
  4. package/lib/commonjs/avatar/Avatar.js.map +1 -1
  5. package/lib/commonjs/avatar/AvatarRing.js +14 -30
  6. package/lib/commonjs/avatar/AvatarRing.js.map +1 -1
  7. package/lib/commonjs/bottom-sheet/index.js +9 -4
  8. package/lib/commonjs/bottom-sheet/index.js.map +1 -1
  9. package/lib/commonjs/connection-status/index.js +16 -7
  10. package/lib/commonjs/connection-status/index.js.map +1 -1
  11. package/lib/commonjs/connection-status/netinfo.js +119 -0
  12. package/lib/commonjs/connection-status/netinfo.js.map +1 -0
  13. package/lib/commonjs/hooks/haptics-module.js +109 -0
  14. package/lib/commonjs/hooks/haptics-module.js.map +1 -0
  15. package/lib/commonjs/hooks/useHaptics.js +6 -8
  16. package/lib/commonjs/hooks/useHaptics.js.map +1 -1
  17. package/lib/commonjs/loading/SpinnerIcon.js +22 -55
  18. package/lib/commonjs/loading/SpinnerIcon.js.map +1 -1
  19. package/lib/commonjs/theme/adaptive-colors.js +20 -4
  20. package/lib/commonjs/theme/adaptive-colors.js.map +1 -1
  21. package/lib/commonjs/theme/color-scope/style-builder.js +107 -6
  22. package/lib/commonjs/theme/color-scope/style-builder.js.map +1 -1
  23. package/lib/commonjs/theme/init-css-interop.js +13 -11
  24. package/lib/commonjs/theme/init-css-interop.js.map +1 -1
  25. package/lib/module/animated-check/AnimatedCheck.js +22 -91
  26. package/lib/module/animated-check/AnimatedCheck.js.map +1 -1
  27. package/lib/module/avatar/Avatar.js +1 -19
  28. package/lib/module/avatar/Avatar.js.map +1 -1
  29. package/lib/module/avatar/AvatarRing.js +7 -23
  30. package/lib/module/avatar/AvatarRing.js.map +1 -1
  31. package/lib/module/bottom-sheet/index.js +9 -4
  32. package/lib/module/bottom-sheet/index.js.map +1 -1
  33. package/lib/module/connection-status/index.js +16 -6
  34. package/lib/module/connection-status/index.js.map +1 -1
  35. package/lib/module/connection-status/netinfo.js +114 -0
  36. package/lib/module/connection-status/netinfo.js.map +1 -0
  37. package/lib/module/hooks/haptics-module.js +104 -0
  38. package/lib/module/hooks/haptics-module.js.map +1 -0
  39. package/lib/module/hooks/useHaptics.js +7 -11
  40. package/lib/module/hooks/useHaptics.js.map +1 -1
  41. package/lib/module/loading/SpinnerIcon.js +13 -46
  42. package/lib/module/loading/SpinnerIcon.js.map +1 -1
  43. package/lib/module/loading/index.web.js +2 -2
  44. package/lib/module/theme/adaptive-colors.js +20 -4
  45. package/lib/module/theme/adaptive-colors.js.map +1 -1
  46. package/lib/module/theme/color-scope/style-builder.js +107 -6
  47. package/lib/module/theme/color-scope/style-builder.js.map +1 -1
  48. package/lib/module/theme/init-css-interop.js +13 -11
  49. package/lib/module/theme/init-css-interop.js.map +1 -1
  50. package/lib/typescript/commonjs/animated-check/AnimatedCheck.d.ts.map +1 -1
  51. package/lib/typescript/commonjs/avatar/Avatar.d.ts.map +1 -1
  52. package/lib/typescript/commonjs/avatar/AvatarRing.d.ts.map +1 -1
  53. package/lib/typescript/commonjs/bottom-sheet/index.d.ts.map +1 -1
  54. package/lib/typescript/commonjs/connection-status/index.d.ts.map +1 -1
  55. package/lib/typescript/commonjs/connection-status/netinfo.d.ts +69 -0
  56. package/lib/typescript/commonjs/connection-status/netinfo.d.ts.map +1 -0
  57. package/lib/typescript/commonjs/hooks/haptics-module.d.ts +58 -0
  58. package/lib/typescript/commonjs/hooks/haptics-module.d.ts.map +1 -0
  59. package/lib/typescript/commonjs/hooks/useHaptics.d.ts.map +1 -1
  60. package/lib/typescript/commonjs/loading/SpinnerIcon.d.ts +9 -2
  61. package/lib/typescript/commonjs/loading/SpinnerIcon.d.ts.map +1 -1
  62. package/lib/typescript/commonjs/theme/adaptive-colors.d.ts.map +1 -1
  63. package/lib/typescript/commonjs/theme/color-scope/style-builder.d.ts.map +1 -1
  64. package/lib/typescript/commonjs/theme/init-css-interop.d.ts.map +1 -1
  65. package/lib/typescript/module/animated-check/AnimatedCheck.d.ts.map +1 -1
  66. package/lib/typescript/module/avatar/Avatar.d.ts.map +1 -1
  67. package/lib/typescript/module/avatar/AvatarRing.d.ts.map +1 -1
  68. package/lib/typescript/module/bottom-sheet/index.d.ts.map +1 -1
  69. package/lib/typescript/module/connection-status/index.d.ts.map +1 -1
  70. package/lib/typescript/module/connection-status/netinfo.d.ts +69 -0
  71. package/lib/typescript/module/connection-status/netinfo.d.ts.map +1 -0
  72. package/lib/typescript/module/hooks/haptics-module.d.ts +58 -0
  73. package/lib/typescript/module/hooks/haptics-module.d.ts.map +1 -0
  74. package/lib/typescript/module/hooks/useHaptics.d.ts.map +1 -1
  75. package/lib/typescript/module/loading/SpinnerIcon.d.ts +9 -2
  76. package/lib/typescript/module/loading/SpinnerIcon.d.ts.map +1 -1
  77. package/lib/typescript/module/theme/adaptive-colors.d.ts.map +1 -1
  78. package/lib/typescript/module/theme/color-scope/style-builder.d.ts.map +1 -1
  79. package/lib/typescript/module/theme/init-css-interop.d.ts.map +1 -1
  80. package/package.json +11 -7
  81. package/src/__tests__/color-scope-nativewind.test.ts +135 -0
  82. package/src/__tests__/connection-status-netinfo.test.tsx +203 -0
  83. package/src/__tests__/optional-peer-imports.test.ts +441 -0
  84. package/src/__tests__/svg-required-peer.test.tsx +75 -0
  85. package/src/__tests__/useHaptics.test.tsx +203 -16
  86. package/src/animated-check/AnimatedCheck.tsx +19 -86
  87. package/src/avatar/Avatar.tsx +1 -11
  88. package/src/avatar/AvatarRing.tsx +7 -15
  89. package/src/bottom-sheet/index.tsx +9 -4
  90. package/src/connection-status/index.tsx +16 -6
  91. package/src/connection-status/netinfo.ts +131 -0
  92. package/src/hooks/haptics-module.ts +118 -0
  93. package/src/hooks/useHaptics.tsx +6 -9
  94. package/src/loading/SpinnerIcon.tsx +13 -52
  95. package/src/loading/index.web.ts +2 -2
  96. package/src/theme/adaptive-colors.ts +20 -4
  97. package/src/theme/color-scope/style-builder.ts +118 -6
  98. package/src/theme/init-css-interop.ts +13 -11
  99. package/lib/commonjs/avatar/svg-module.js +0 -15
  100. package/lib/commonjs/avatar/svg-module.js.map +0 -1
  101. package/lib/commonjs/utils/lazy-require.js +0 -37
  102. package/lib/commonjs/utils/lazy-require.js.map +0 -1
  103. package/lib/module/avatar/svg-module.js +0 -11
  104. package/lib/module/avatar/svg-module.js.map +0 -1
  105. package/lib/module/utils/lazy-require.js +0 -33
  106. package/lib/module/utils/lazy-require.js.map +0 -1
  107. package/lib/typescript/commonjs/avatar/svg-module.d.ts +0 -8
  108. package/lib/typescript/commonjs/avatar/svg-module.d.ts.map +0 -1
  109. package/lib/typescript/commonjs/utils/lazy-require.d.ts +0 -15
  110. package/lib/typescript/commonjs/utils/lazy-require.d.ts.map +0 -1
  111. package/lib/typescript/module/avatar/svg-module.d.ts +0 -8
  112. package/lib/typescript/module/avatar/svg-module.d.ts.map +0 -1
  113. package/lib/typescript/module/utils/lazy-require.d.ts +0 -15
  114. package/lib/typescript/module/utils/lazy-require.d.ts.map +0 -1
  115. package/src/avatar/svg-module.ts +0 -11
  116. package/src/utils/lazy-require.ts +0 -31
@@ -1 +1 @@
1
- {"version":3,"file":"SpinnerIcon.d.ts","sourceRoot":"","sources":["../../../../src/loading/SpinnerIcon.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAoB,MAAM,OAAO,CAAC;AACzC,OAAO,EAAqB,KAAK,SAAS,EAAE,MAAM,cAAc,CAAC;AAqBjE,UAAU,gBAAgB;IACxB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,SAAS,CAAC;CACnB;AAwED;;;;GAIG;AACH,eAAO,MAAM,WAAW,EAAE,KAAK,CAAC,EAAE,CAAC,gBAAgB,CAqBlD,CAAC"}
1
+ {"version":3,"file":"SpinnerIcon.d.ts","sourceRoot":"","sources":["../../../../src/loading/SpinnerIcon.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAoB,MAAM,OAAO,CAAC;AACzC,OAAO,EAAE,KAAK,SAAS,EAAE,MAAM,cAAc,CAAC;AAU9C,UAAU,gBAAgB;IACxB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,SAAS,CAAC;CACnB;AAED;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,WAAW,EAAE,KAAK,CAAC,EAAE,CAAC,gBAAgB,CAwDlD,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"adaptive-colors.d.ts","sourceRoot":"","sources":["../../../../src/theme/adaptive-colors.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAgG3C,wBAAgB,iBAAiB,IAAI,WAAW,GAAG,IAAI,CAItD"}
1
+ {"version":3,"file":"adaptive-colors.d.ts","sourceRoot":"","sources":["../../../../src/theme/adaptive-colors.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAgH3C,wBAAgB,iBAAiB,IAAI,WAAW,GAAG,IAAI,CAItD"}
@@ -1 +1 @@
1
- {"version":3,"file":"style-builder.d.ts","sourceRoot":"","sources":["../../../../../src/theme/color-scope/style-builder.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAC/B,OAAO,EAAY,KAAK,SAAS,EAAE,KAAK,SAAS,EAAE,MAAM,cAAc,CAAC;AAGxE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AACrD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAGtD;;;;;GAKG;AACH,MAAM,MAAM,gCAAgC,GAAG,KAAK,CAAC,aAAa,CAAC;IACjE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC9B,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;CAC3B,CAAC,CAAC;AASH;;;;;;;;;GASG;AACH,wBAAgB,cAAc,CAC5B,WAAW,EAAE,YAAY,EACzB,IAAI,EAAE,OAAO,GAAG,MAAM,EACtB,OAAO,CAAC,EAAE,eAAe,GACxB,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAYxB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,0BAA0B,IAAI,gCAAgC,GAAG,IAAI,CAKpF;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,sBAAsB,CACpC,WAAW,EAAE,YAAY,EACzB,IAAI,EAAE,OAAO,GAAG,MAAM,GACrB,SAAS,CAAC,SAAS,CAAC,CAKtB"}
1
+ {"version":3,"file":"style-builder.d.ts","sourceRoot":"","sources":["../../../../../src/theme/color-scope/style-builder.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAC/B,OAAO,EAAY,KAAK,SAAS,EAAE,KAAK,SAAS,EAAE,MAAM,cAAc,CAAC;AAGxE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AACrD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AA6CtD;;;;;GAKG;AACH,MAAM,MAAM,gCAAgC,GAAG,KAAK,CAAC,aAAa,CAAC;IACjE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC9B,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;CAC3B,CAAC,CAAC;AAkEH;;;;;;;;;GASG;AACH,wBAAgB,cAAc,CAC5B,WAAW,EAAE,YAAY,EACzB,IAAI,EAAE,OAAO,GAAG,MAAM,EACtB,OAAO,CAAC,EAAE,eAAe,GACxB,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAYxB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,0BAA0B,IAAI,gCAAgC,GAAG,IAAI,CAepF;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,sBAAsB,CACpC,WAAW,EAAE,YAAY,EACzB,IAAI,EAAE,OAAO,GAAG,MAAM,GACrB,SAAS,CAAC,SAAS,CAAC,CAQtB"}
@@ -1 +1 @@
1
- {"version":3,"file":"init-css-interop.d.ts","sourceRoot":"","sources":["../../../../src/theme/init-css-interop.ts"],"names":[],"mappings":"AAuHA;;;GAGG;AACH,wBAAgB,sBAAsB,IAAI,IAAI,CAS7C"}
1
+ {"version":3,"file":"init-css-interop.d.ts","sourceRoot":"","sources":["../../../../src/theme/init-css-interop.ts"],"names":[],"mappings":"AAyHA;;;GAGG;AACH,wBAAgB,sBAAsB,IAAI,IAAI,CAS7C"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/bloom",
3
- "version": "0.68.0",
3
+ "version": "0.69.1",
4
4
  "description": "Bloom UI — Oxy ecosystem component library for React Native + Expo + Web",
5
5
  "main": "lib/commonjs/index.js",
6
6
  "module": "lib/module/index.js",
@@ -1490,6 +1490,7 @@
1490
1490
  "@react-native-community/netinfo": "^12.0.1"
1491
1491
  },
1492
1492
  "peerDependencies": {
1493
+ "@react-native-community/netinfo": ">=11.1.0",
1493
1494
  "expo": "*",
1494
1495
  "expo-blur": "*",
1495
1496
  "expo-font": "*",
@@ -1498,18 +1499,21 @@
1498
1499
  "expo-image": "*",
1499
1500
  "expo-router": ">=3.0.0",
1500
1501
  "expo-symbols": ">=0.4.5",
1502
+ "nativewind": ">=5.0.0",
1501
1503
  "react": ">=18.0.0",
1502
1504
  "react-dom": ">=18.0.0",
1503
1505
  "react-native": ">=0.73.0",
1504
1506
  "react-native-gesture-handler": ">=2.16.1",
1507
+ "react-native-keyboard-controller": ">=1.11.4",
1505
1508
  "react-native-reanimated": ">=3.13.0",
1506
1509
  "react-native-safe-area-context": ">=5.0.0",
1507
1510
  "react-native-screens": ">=3.16.0",
1508
- "react-native-svg": ">=13.0.0",
1509
- "nativewind": ">=5.0.0",
1510
- "@react-native-community/netinfo": ""
1511
+ "react-native-svg": ">=13.0.0"
1511
1512
  },
1512
1513
  "peerDependenciesMeta": {
1514
+ "@react-native-community/netinfo": {
1515
+ "optional": true
1516
+ },
1513
1517
  "expo": {
1514
1518
  "optional": true
1515
1519
  },
@@ -1522,13 +1526,13 @@
1522
1526
  "expo-router": {
1523
1527
  "optional": true
1524
1528
  },
1525
- "react-dom": {
1529
+ "nativewind": {
1526
1530
  "optional": true
1527
1531
  },
1528
- "nativewind": {
1532
+ "react-dom": {
1529
1533
  "optional": true
1530
1534
  },
1531
- "@react-native-community/netinfo": {
1535
+ "react-native-keyboard-controller": {
1532
1536
  "optional": true
1533
1537
  }
1534
1538
  },
@@ -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
+ });