@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.
- 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/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/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/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 +347 -87
- package/src/__tests__/exports-map-contract.test.ts +112 -0
- package/src/__tests__/toast-single-outlet-guard.test.tsx +176 -0
- package/src/toast/Toaster.tsx +6 -0
- package/src/toast/use-single-outlet-guard.ts +72 -0
|
@@ -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
|
+
});
|
package/src/toast/Toaster.tsx
CHANGED
|
@@ -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
|
+
}
|