@oxyhq/bloom 0.66.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/dialog/Dialog.web.js +31 -13
- package/lib/commonjs/dialog/Dialog.web.js.map +1 -1
- package/lib/commonjs/toast/Toaster.js +5 -0
- package/lib/commonjs/toast/Toaster.js.map +1 -1
- package/lib/commonjs/toast/use-single-outlet-guard.js +72 -0
- package/lib/commonjs/toast/use-single-outlet-guard.js.map +1 -0
- package/lib/module/dialog/Dialog.web.js +31 -12
- package/lib/module/dialog/Dialog.web.js.map +1 -1
- package/lib/module/toast/Toaster.js +5 -0
- package/lib/module/toast/Toaster.js.map +1 -1
- package/lib/module/toast/use-single-outlet-guard.js +65 -0
- package/lib/module/toast/use-single-outlet-guard.js.map +1 -0
- package/lib/typescript/commonjs/dialog/Dialog.web.d.ts +1 -3
- package/lib/typescript/commonjs/dialog/Dialog.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/toast/Toaster.d.ts.map +1 -1
- package/lib/typescript/commonjs/toast/use-single-outlet-guard.d.ts +9 -0
- package/lib/typescript/commonjs/toast/use-single-outlet-guard.d.ts.map +1 -0
- package/lib/typescript/module/dialog/Dialog.web.d.ts +1 -3
- package/lib/typescript/module/dialog/Dialog.web.d.ts.map +1 -1
- package/lib/typescript/module/toast/Toaster.d.ts.map +1 -1
- package/lib/typescript/module/toast/use-single-outlet-guard.d.ts +9 -0
- package/lib/typescript/module/toast/use-single-outlet-guard.d.ts.map +1 -0
- package/package.json +347 -87
- package/src/__tests__/Dialog.web.test.tsx +5 -2
- package/src/__tests__/backdrop-fade-form.test.ts +91 -0
- package/src/__tests__/exports-map-contract.test.ts +112 -0
- package/src/__tests__/toast-single-outlet-guard.test.tsx +176 -0
- package/src/dialog/Dialog.web.tsx +32 -10
- package/src/toast/Toaster.tsx +6 -0
- package/src/toast/use-single-outlet-guard.ts +72 -0
|
@@ -77,8 +77,11 @@ describe('Dialog.web keyframe self-injection', () => {
|
|
|
77
77
|
const css = styleEl?.textContent ?? '';
|
|
78
78
|
expect(css).toContain('@keyframes bloomDialogZoomFadeIn');
|
|
79
79
|
expect(css).toContain('@keyframes bloomDialogZoomFadeOut');
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
// The backdrop's own fade is NOT a keyframe: it rides a shared value, so
|
|
81
|
+
// the dim can never be driven past its own opacity (see the black-flash
|
|
82
|
+
// regression below).
|
|
83
|
+
expect(css).not.toContain('@keyframes bloomDialogFadeIn');
|
|
84
|
+
expect(css).not.toContain('@keyframes bloomDialogFadeOut');
|
|
82
85
|
|
|
83
86
|
// Opening the dialog (the path that actually plays the animation) must not
|
|
84
87
|
// duplicate the stylesheet.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// The black flash every centered dialog opened with.
|
|
2
|
+
//
|
|
3
|
+
// The backdrop's fade used to be a CSS `@keyframes opacity: 0 → 1` handed to
|
|
4
|
+
// BOTH of `Backdrop`'s layers through `layerStyle`. A running CSS animation
|
|
5
|
+
// outranks inline styles in the cascade, so on the DIM layer — whose inline
|
|
6
|
+
// opacity IS the dim (0.28) — those keyframes drove opacity all the way to 1,
|
|
7
|
+
// i.e. opaque BLACK, for the length of the animation, then dropped it back to
|
|
8
|
+
// 0.28 the instant it ended. Nothing errored, and a screenshot taken after the
|
|
9
|
+
// 150ms looked perfect; it only ever showed as a flicker behind an opening
|
|
10
|
+
// dialog. A CSS *transition* is fine and is what the side-sheet path uses: it
|
|
11
|
+
// interpolates the inline value instead of overriding it.
|
|
12
|
+
//
|
|
13
|
+
// This is a SOURCE gate on purpose. The DOM cannot answer the question in
|
|
14
|
+
// jest: the layers are a mocked `BlurView` and a mocked reanimated
|
|
15
|
+
// `Animated.View`, so neither the inline style nor a react-native-web class
|
|
16
|
+
// ever reaches jsdom — a DOM-level assertion here passes just as happily with
|
|
17
|
+
// the bug in place (it did, which is why this file looks like this).
|
|
18
|
+
|
|
19
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
|
|
22
|
+
const SRC = join(__dirname, '..');
|
|
23
|
+
|
|
24
|
+
function sourceFiles(dir: string, out: string[] = []): string[] {
|
|
25
|
+
for (const entry of readdirSync(dir)) {
|
|
26
|
+
if (entry === '__tests__' || entry === 'node_modules') continue;
|
|
27
|
+
const full = join(dir, entry);
|
|
28
|
+
if (statSync(full).isDirectory()) {
|
|
29
|
+
sourceFiles(full, out);
|
|
30
|
+
} else if (/\.tsx?$/.test(entry)) {
|
|
31
|
+
out.push(full);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** `layerStyle={X}` → the source text X actually resolves to. */
|
|
38
|
+
function resolveLayerStyles(source: string): string[] {
|
|
39
|
+
const out: string[] = [];
|
|
40
|
+
for (const match of source.matchAll(/layerStyle=\{([^}]*(?:\}[^}]*)?)\}/g)) {
|
|
41
|
+
const expression = match[1] ?? '';
|
|
42
|
+
out.push(expression);
|
|
43
|
+
// An identifier (`layerStyle={BACKDROP_FADE_IN}`) has to be followed home:
|
|
44
|
+
// the animation lived in the constant, not at the call site.
|
|
45
|
+
for (const identifier of expression.matchAll(/\b([A-Za-z_$][\w$]*)\b/g)) {
|
|
46
|
+
const declaration = new RegExp(
|
|
47
|
+
`const ${identifier[1]}[^=]*=\\s*([\\s\\S]{0,400}?);\\n`,
|
|
48
|
+
).exec(source);
|
|
49
|
+
if (declaration?.[1]) out.push(declaration[1]);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
describe('backdrop fade form', () => {
|
|
56
|
+
const files = sourceFiles(SRC);
|
|
57
|
+
|
|
58
|
+
it('scans the whole source tree', () => {
|
|
59
|
+
expect(files.length).toBeGreaterThan(200);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('never drives the backdrop layers with a CSS animation', () => {
|
|
63
|
+
const offenders: string[] = [];
|
|
64
|
+
let layerStyles = 0;
|
|
65
|
+
|
|
66
|
+
for (const file of files) {
|
|
67
|
+
const source = readFileSync(file, 'utf8');
|
|
68
|
+
for (const style of resolveLayerStyles(source)) {
|
|
69
|
+
layerStyles += 1;
|
|
70
|
+
if (/\banimation\b/.test(style)) {
|
|
71
|
+
offenders.push(`${file.slice(SRC.length + 1)} ${style.replace(/\s+/g, ' ').slice(0, 80)}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Vacuity floor: the side-sheet's transition is a real `layerStyle` and must
|
|
77
|
+
// be found, or the scan resolved nothing and this passes for free.
|
|
78
|
+
expect(layerStyles).toBeGreaterThan(0);
|
|
79
|
+
expect(offenders).toEqual([]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// The other half of the contract: the fade is multiplied INTO each layer's own
|
|
83
|
+
// opacity, so a fully-arrived backdrop is still only `dimOpacity` dark.
|
|
84
|
+
it('multiplies the fade into the dim opacity', () => {
|
|
85
|
+
const overlay = readFileSync(join(SRC, 'overlay', 'index.tsx'), 'utf8');
|
|
86
|
+
|
|
87
|
+
expect(overlay).toMatch(
|
|
88
|
+
/const dimFade[\s\S]{0,200}?progress\.value[\s\S]{0,80}?\* dimOpacity/,
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -16,7 +16,11 @@ import {
|
|
|
16
16
|
type StyleProp,
|
|
17
17
|
type ViewStyle,
|
|
18
18
|
} from 'react-native';
|
|
19
|
-
import Animated
|
|
19
|
+
import Animated, {
|
|
20
|
+
Easing,
|
|
21
|
+
useSharedValue,
|
|
22
|
+
withTiming,
|
|
23
|
+
} from 'react-native-reanimated';
|
|
20
24
|
import { RemoveScrollBar } from 'react-remove-scroll-bar';
|
|
21
25
|
|
|
22
26
|
import { Backdrop, OverlayRoot } from '../overlay';
|
|
@@ -72,10 +76,11 @@ const ZOOM_FADE_IN: WebCssStyle = {
|
|
|
72
76
|
const ZOOM_FADE_OUT: WebCssStyle = {
|
|
73
77
|
animation: `bloomDialogZoomFadeOut ease-in ${FADE_OUT_DURATION}ms forwards`,
|
|
74
78
|
};
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
+
/**
|
|
80
|
+
* Backdrop fade-in duration (ms). The card's zoom-fade is longer; the dim
|
|
81
|
+
* arriving first is what makes the card read as landing ON something.
|
|
82
|
+
*/
|
|
83
|
+
const BACKDROP_FADE_IN_DURATION = 150;
|
|
79
84
|
|
|
80
85
|
const stopPropagation = (e: { stopPropagation: () => void }) => e.stopPropagation();
|
|
81
86
|
|
|
@@ -279,6 +284,27 @@ function CenterOrSideDialog({
|
|
|
279
284
|
return () => clearTimeout(timer);
|
|
280
285
|
}, [isClosing, exitDuration]);
|
|
281
286
|
|
|
287
|
+
// The backdrop's fade rides a shared value, NOT a CSS `@keyframes opacity
|
|
288
|
+
// 0 → 1` handed to the layers. A running CSS animation outranks inline styles
|
|
289
|
+
// in the cascade, so those keyframes drove the DIM layer — whose inline
|
|
290
|
+
// opacity IS the dim (0.28) — all the way to 1, i.e. opaque black, and then
|
|
291
|
+
// dropped it back to 0.28 the instant the animation ended. That is the black
|
|
292
|
+
// flash every centered dialog opened with. `progress` is multiplied INTO each
|
|
293
|
+
// layer's own opacity by `Backdrop`, so the dim can only ever reach its own
|
|
294
|
+
// value. (The side-sheet path was never affected: it animates with a CSS
|
|
295
|
+
// *transition*, which interpolates the inline value instead of overriding it.)
|
|
296
|
+
const backdropFade = useSharedValue(0);
|
|
297
|
+
|
|
298
|
+
useEffect(() => {
|
|
299
|
+
if (!isOpen) {
|
|
300
|
+
backdropFade.value = 0;
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
backdropFade.value = isClosing
|
|
304
|
+
? withTiming(0, { duration: FADE_OUT_DURATION, easing: Easing.in(Easing.ease) })
|
|
305
|
+
: withTiming(1, { duration: BACKDROP_FADE_IN_DURATION, easing: Easing.out(Easing.ease) });
|
|
306
|
+
}, [backdropFade, isOpen, isClosing]);
|
|
307
|
+
|
|
282
308
|
// Escape-to-close while open. The listener is intentionally scoped to the
|
|
283
309
|
// open lifetime so stacked dialogs don't fight for the keydown — the
|
|
284
310
|
// top-most one wins via document-level event order. Escape honors
|
|
@@ -328,7 +354,7 @@ function CenterOrSideDialog({
|
|
|
328
354
|
// The fade rides on the LAYERS, never on the press target: an
|
|
329
355
|
// opacity animation on the blur's ancestor composites the group in
|
|
330
356
|
// isolation and leaves `backdrop-filter` nothing to sample.
|
|
331
|
-
|
|
357
|
+
progress={backdropFade}
|
|
332
358
|
style={{
|
|
333
359
|
position: WEB_POSITION_FIXED,
|
|
334
360
|
zIndex: dialogZIndex.backdrop,
|
|
@@ -877,8 +903,6 @@ const sheetStyles: Record<'root' | 'backdrop' | 'panel', ViewStyle> = {
|
|
|
877
903
|
* injection is guarded by a unique style id.
|
|
878
904
|
*
|
|
879
905
|
* ```css
|
|
880
|
-
* @keyframes bloomDialogFadeIn { from { opacity: 0; } to { opacity: 1; } }
|
|
881
|
-
* @keyframes bloomDialogFadeOut { from { opacity: 1; } to { opacity: 0; } }
|
|
882
906
|
* @keyframes bloomDialogZoomFadeIn { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } }
|
|
883
907
|
* @keyframes bloomDialogZoomFadeOut { from { opacity: 1; transform: scale(1); } to { opacity: 0; transform: scale(0.95); } }
|
|
884
908
|
* ```
|
|
@@ -888,8 +912,6 @@ const sheetStyles: Record<'root' | 'backdrop' | 'panel', ViewStyle> = {
|
|
|
888
912
|
* `bottom` placement uses bloom's `BottomSheet` (reanimated) and needs none.
|
|
889
913
|
*/
|
|
890
914
|
export const BLOOM_DIALOG_CSS = `
|
|
891
|
-
@keyframes bloomDialogFadeIn { from { opacity: 0; } to { opacity: 1; } }
|
|
892
|
-
@keyframes bloomDialogFadeOut { from { opacity: 1; } to { opacity: 0; } }
|
|
893
915
|
@keyframes bloomDialogZoomFadeIn { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } }
|
|
894
916
|
@keyframes bloomDialogZoomFadeOut { from { opacity: 1; transform: scale(1); } to { opacity: 0; transform: scale(0.95); } }
|
|
895
917
|
`;
|
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
|
+
}
|