@oxyhq/bloom 0.67.0 → 0.68.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.
@@ -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
+ });
@@ -0,0 +1,176 @@
1
+ import React from 'react';
2
+ import { render } from '@testing-library/react-native';
3
+
4
+ import { ToastOutlet } from '../toast';
5
+ import { resetSingleOutletGuardForTests } from '../toast/use-single-outlet-guard';
6
+
7
+ /**
8
+ * THE DUPLICATE-OUTLET GUARD.
9
+ *
10
+ * `Toaster` subscribes to the module-level `toastStore`, so two mounted outlets
11
+ * render two copies of every row — measured 1→1, 2→2, 3→3 in Mention, and 1→2→1
12
+ * in Alia with the second outlet gated on a query param inside ONE bundle so both
13
+ * arms came from identical bytes. The copies overlap almost exactly (bare outlets
14
+ * take identical defaults and land pixel-identical), so nothing but a row count
15
+ * reveals it — hence a warning.
16
+ *
17
+ * Every test here mounts outlets and fires NO toast: a second outlet duplicates
18
+ * rows from the moment it mounts, so the warning must not wait for one. An idle
19
+ * `Toaster` renders null, which is why these need no theme provider or portal.
20
+ *
21
+ * The guard's module state outlives a render tree, so `beforeEach` resets it.
22
+ * Without that, the first test to latch the warning would leave every later test
23
+ * asserting "did not warn" against a guard that can no longer fire — green
24
+ * whatever the implementation does. That vacuity is the specific failure mode
25
+ * this suite is mutation-tested against; see the per-test mutation notes.
26
+ */
27
+ describe('duplicate toast outlet guard', () => {
28
+ let warn: jest.SpyInstance<void, Parameters<typeof console.warn>>;
29
+
30
+ beforeEach(() => {
31
+ resetSingleOutletGuardForTests();
32
+ // `__mocks__/setup.ts` already silences console.warn; this re-spies so the
33
+ // calls are recorded and stay scoped to one test.
34
+ warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
35
+ });
36
+
37
+ afterEach(() => {
38
+ warn.mockRestore();
39
+ });
40
+
41
+ const bloomWarnings = () =>
42
+ warn.mock.calls.filter(
43
+ (call) => typeof call[0] === 'string' && call[0].startsWith('[Bloom] Toaster:'),
44
+ );
45
+
46
+ /** Mutation: `mountedOutlets > 1` → `>= 1` warns here. */
47
+ it('stays silent for a single outlet', () => {
48
+ render(<ToastOutlet />);
49
+
50
+ expect(bloomWarnings()).toHaveLength(0);
51
+ });
52
+
53
+ it('warns exactly once when a second outlet mounts', () => {
54
+ render(
55
+ <>
56
+ <ToastOutlet />
57
+ <ToastOutlet />
58
+ </>,
59
+ );
60
+
61
+ expect(bloomWarnings()).toHaveLength(1);
62
+ });
63
+
64
+ it('names the cause rather than just the symptom', () => {
65
+ render(
66
+ <>
67
+ <ToastOutlet />
68
+ <ToastOutlet />
69
+ </>,
70
+ );
71
+
72
+ // The consumer's tree is the only place this is fixable, and the overwhelming
73
+ // cause is an app mounting its own outlet beside OxyProvider's.
74
+ expect(bloomWarnings()[0]?.[0]).toContain('OxyProvider');
75
+ });
76
+
77
+ /** Mutation: drop the `hasWarned` latch and this warns twice. */
78
+ it('warns once, not once per extra outlet, when a third mounts', () => {
79
+ render(
80
+ <>
81
+ <ToastOutlet />
82
+ <ToastOutlet />
83
+ <ToastOutlet />
84
+ </>,
85
+ );
86
+
87
+ expect(bloomWarnings()).toHaveLength(1);
88
+ });
89
+
90
+ /**
91
+ * Mutation: drop the unmount decrement and the second mount reads a count of 2
92
+ * and warns. This is the counter-never-resets failure mode.
93
+ */
94
+ it('does not warn across an unmount / remount cycle', () => {
95
+ render(<ToastOutlet />).unmount();
96
+ render(<ToastOutlet />);
97
+
98
+ expect(bloomWarnings()).toHaveLength(0);
99
+ });
100
+
101
+ /** Three cycles: a counter that leaks by one per mount crosses the threshold. */
102
+ it('does not warn across repeated remounts', () => {
103
+ render(<ToastOutlet />).unmount();
104
+ render(<ToastOutlet />).unmount();
105
+ render(<ToastOutlet />);
106
+
107
+ expect(bloomWarnings()).toHaveLength(0);
108
+ });
109
+
110
+ /**
111
+ * A route change or provider reshuffle that moves the outlet: React runs the
112
+ * outgoing tree's cleanups before the incoming tree's setups, so the count dips
113
+ * to 0 and back to 1 rather than reaching 2.
114
+ */
115
+ it('does not warn when one outlet replaces another', () => {
116
+ const first = render(<ToastOutlet />);
117
+ const second = render(<ToastOutlet />);
118
+ first.unmount();
119
+ second.unmount();
120
+ render(<ToastOutlet />);
121
+
122
+ // Two were genuinely co-mounted in the middle, so one warning is correct...
123
+ expect(bloomWarnings()).toHaveLength(1);
124
+ });
125
+
126
+ /**
127
+ * StrictMode is the dominant dev environment and it replays effects
128
+ * (setup → cleanup → setup), so without the latch a real duplicate would warn
129
+ * TWICE and a well-behaved single outlet could warn at all. Verified against
130
+ * this renderer: it does double-invoke, so neither case below is vacuous.
131
+ */
132
+ describe('under StrictMode double-invoked effects', () => {
133
+ it('still stays silent for a single outlet', () => {
134
+ render(
135
+ <React.StrictMode>
136
+ <ToastOutlet />
137
+ </React.StrictMode>,
138
+ );
139
+
140
+ expect(bloomWarnings()).toHaveLength(0);
141
+ });
142
+
143
+ it('still warns exactly once for a duplicate', () => {
144
+ render(
145
+ <React.StrictMode>
146
+ <ToastOutlet />
147
+ <ToastOutlet />
148
+ </React.StrictMode>,
149
+ );
150
+
151
+ expect(bloomWarnings()).toHaveLength(1);
152
+ });
153
+ });
154
+
155
+ /**
156
+ * Mutation: drop the `process.env.NODE_ENV` gate and this warns. Bundlers fold
157
+ * the branch statically, so production ships neither the counter nor the
158
+ * message — the guard must not change production behaviour at all.
159
+ */
160
+ it('does not warn in production', () => {
161
+ const previous = process.env.NODE_ENV;
162
+ process.env.NODE_ENV = 'production';
163
+ try {
164
+ render(
165
+ <>
166
+ <ToastOutlet />
167
+ <ToastOutlet />
168
+ </>,
169
+ );
170
+ } finally {
171
+ process.env.NODE_ENV = previous;
172
+ }
173
+
174
+ expect(bloomWarnings()).toHaveLength(0);
175
+ });
176
+ });
@@ -30,6 +30,7 @@ import type {
30
30
  ToastProps,
31
31
  } from './types';
32
32
  import { useAppStateChange } from './use-app-state';
33
+ import { useSingleOutletGuard } from './use-single-outlet-guard';
33
34
 
34
35
  const ALL_POSITIONS: ToastPosition[] = [
35
36
  'top-center',
@@ -79,6 +80,11 @@ export const Toaster: React.FC<ToasterProps> = ({
79
80
  style,
80
81
  styles: styleOverrides,
81
82
  }) => {
83
+ // Before any early return, and independent of whether a toast is up: a second
84
+ // outlet duplicates every row from the moment it mounts, not from the first
85
+ // toast, so the warning must not wait for one.
86
+ useSingleOutletGuard();
87
+
82
88
  const { toasts, shouldShowOverlay, toastHeights, isExpanded } =
83
89
  React.useSyncExternalStore(
84
90
  toastStore.subscribe,
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Bloom-original — the dev-only guard against a SECOND toast outlet.
3
+ *
4
+ * Every `Toaster` subscribes to the ONE module-level `toastStore`, which has no
5
+ * notion of an owning outlet, so N mounted outlets each render the full row set:
6
+ * a toast appears N times. Nothing in the engine can dedupe that — the outlets
7
+ * are legitimate, independent subscribers — so the contract is "mount exactly
8
+ * one" and this is what enforces it.
9
+ *
10
+ * WHY A WARNING IS WORTH ITS WEIGHT: the copies are not visually separated, so
11
+ * the defect does not look like duplication. Two bare outlets take identical
12
+ * defaults and land pixel-identical; two outlets whose `offset` differs by a
13
+ * point land 1px apart. Only counting the rendered rows reveals it, which is not
14
+ * something anyone thinks to do — the reports this guard comes from all read as
15
+ * "the toast looks wrong", never as "there are two of them". Stacking is on by
16
+ * default, so a duplicate outlet now doubles the rows INSIDE a stack.
17
+ *
18
+ * The counter is decremented on unmount, so a remount, a route change that moves
19
+ * the outlet, Fast Refresh and React's StrictMode double-invoke (setup, cleanup,
20
+ * setup) all pass through a count of 1 and stay silent. The latch keeps the
21
+ * warning to exactly one per module lifetime rather than one per extra outlet,
22
+ * and — because StrictMode replays the setups — one per app, not two.
23
+ *
24
+ * Gated on `process.env.NODE_ENV`, the same mechanism as
25
+ * `content-panel/nesting-context.ts`: Metro and Vite/Rolldown both fold it
26
+ * statically, so production keeps the counter, the branch and the message out of
27
+ * the bundle entirely. Deliberately NOT `__DEV__`, which is a Metro global that a
28
+ * plain web bundler does not define. It only ever warns — a duplicate outlet
29
+ * renders a redundant toast, it does not break the app, so it must never throw.
30
+ */
31
+ import * as React from 'react';
32
+
33
+ let mountedOutlets = 0;
34
+ let hasWarned = false;
35
+
36
+ export function useSingleOutletGuard(): void {
37
+ React.useEffect(() => {
38
+ if (process.env.NODE_ENV === 'production') {
39
+ return;
40
+ }
41
+
42
+ mountedOutlets += 1;
43
+ if (mountedOutlets > 1 && !hasWarned) {
44
+ hasWarned = true;
45
+ // Internal Bloom diagnostic: the consumer's tree is the only place this
46
+ // can be fixed, so it names the usual cause rather than just the symptom.
47
+ // eslint-disable-next-line no-console
48
+ console.warn(
49
+ `[Bloom] Toaster: ${mountedOutlets} toast outlets are mounted, so every ` +
50
+ 'toast renders once per outlet. The copies overlap almost exactly, so ' +
51
+ 'this reads as a rendering glitch rather than as duplicate rows. Mount ' +
52
+ "exactly one: @oxyhq/services' OxyProvider already renders a " +
53
+ 'ToastOutlet, so an app that uses it must not render its own.',
54
+ );
55
+ }
56
+
57
+ return () => {
58
+ mountedOutlets -= 1;
59
+ };
60
+ }, []);
61
+ }
62
+
63
+ /**
64
+ * Test-only reset: both the counter and the one-shot latch are module state that
65
+ * outlives a render tree, so without this the first suite to mount two outlets
66
+ * would latch the warning and leave every later suite asserting against a guard
67
+ * that can no longer fire — passing whatever the implementation did.
68
+ */
69
+ export function resetSingleOutletGuardForTests(): void {
70
+ mountedOutlets = 0;
71
+ hasWarned = false;
72
+ }