@symbiote-native/splash-screen 0.1.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 (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +145 -0
  3. package/build-ngc/angular/index.d.ts +3 -0
  4. package/build-ngc/angular/index.js +6 -0
  5. package/build-ngc/angular/index.js.map +1 -0
  6. package/build-ngc/angular/services/hide-animation.service.d.ts +9 -0
  7. package/build-ngc/angular/services/hide-animation.service.js +32 -0
  8. package/build-ngc/angular/services/hide-animation.service.js.map +1 -0
  9. package/build-ngc/core/compute-hide-animation-styles.d.ts +3 -0
  10. package/build-ngc/core/compute-hide-animation-styles.js +78 -0
  11. package/build-ngc/core/compute-hide-animation-styles.js.map +1 -0
  12. package/build-ngc/core/hide-animation-controller.d.ts +10 -0
  13. package/build-ngc/core/hide-animation-controller.js +51 -0
  14. package/build-ngc/core/hide-animation-controller.js.map +1 -0
  15. package/build-ngc/core/hide.d.ts +1 -0
  16. package/build-ngc/core/hide.js +5 -0
  17. package/build-ngc/core/hide.js.map +1 -0
  18. package/build-ngc/core/index.d.ts +5 -0
  19. package/build-ngc/core/index.js +5 -0
  20. package/build-ngc/core/index.js.map +1 -0
  21. package/build-ngc/core/native-module.d.ts +2 -0
  22. package/build-ngc/core/native-module.js +13 -0
  23. package/build-ngc/core/native-module.js.map +1 -0
  24. package/build-ngc/core/types.d.ts +51 -0
  25. package/build-ngc/core/types.js +2 -0
  26. package/build-ngc/core/types.js.map +1 -0
  27. package/cli.cjs +23 -0
  28. package/package.json +118 -0
  29. package/react-native.config.cjs +39 -0
  30. package/src/angular/index.ts +6 -0
  31. package/src/angular/services/hide-animation.service.test.ts +205 -0
  32. package/src/angular/services/hide-animation.service.ts +35 -0
  33. package/src/core/compute-hide-animation-styles.ts +111 -0
  34. package/src/core/hide-animation-controller.ts +70 -0
  35. package/src/core/hide.ts +4 -0
  36. package/src/core/index.ts +13 -0
  37. package/src/core/native-module.ts +19 -0
  38. package/src/core/types.ts +62 -0
  39. package/src/react/hooks/use-hide-animation.test.tsx +114 -0
  40. package/src/react/hooks/use-hide-animation.ts +40 -0
  41. package/src/react/index.ts +9 -0
  42. package/src/vue/composables/use-hide-animation.test.ts +199 -0
  43. package/src/vue/composables/use-hide-animation.ts +32 -0
  44. package/src/vue/index.ts +9 -0
  45. package/symbiote-splash-screen.podspec +64 -0
@@ -0,0 +1,39 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const PACKAGE_NAME = '@symbiote-native/splash-screen';
5
+ const NATIVE_SPLASH_SCREEN_PACKAGE = 'react-native-bootsplash';
6
+
7
+ function findCliDependencyRoot() {
8
+ let current = process.cwd();
9
+
10
+ while (true) {
11
+ const candidate = path.join(current, 'node_modules', ...PACKAGE_NAME.split('/'));
12
+ if (fs.existsSync(candidate)) {
13
+ return candidate;
14
+ }
15
+
16
+ const parent = path.dirname(current);
17
+ if (parent === current) {
18
+ return __dirname;
19
+ }
20
+
21
+ current = parent;
22
+ }
23
+ }
24
+
25
+ const cliDependencyRoot = findCliDependencyRoot();
26
+ const nativeSplashScreenRoot = path.dirname(
27
+ require.resolve(`${NATIVE_SPLASH_SCREEN_PACKAGE}/package.json`),
28
+ );
29
+
30
+ module.exports = {
31
+ dependency: {
32
+ platforms: {
33
+ android: {
34
+ sourceDir: path.relative(cliDependencyRoot, path.join(nativeSplashScreenRoot, 'android')),
35
+ },
36
+ ios: {},
37
+ },
38
+ },
39
+ };
@@ -0,0 +1,6 @@
1
+ // hide()/isVisible() carry zero framework dependency — re-exported verbatim from core, same as
2
+ // every other adapter. HideAnimationService is the Angular-only lifecycle half; the state
3
+ // machine, style computation, and native-constants read all live in core, shared with React/Vue.
4
+ export { hide, isVisible } from '../core';
5
+ export { HideAnimationService } from './services/hide-animation.service';
6
+ export type { IHideAnimationConfig, IHideAnimationResult, IManifest, IHideConfig } from '../core';
@@ -0,0 +1,205 @@
1
+ // Co-located Angular-driven test (ADR 0025) for HideAnimationService. Mounts a real host
2
+ // component through @symbiote-native/angular so `connect()` runs the same way an app would call
3
+ // it — inside the component's own injection context — and drives the returned signal through a
4
+ // full mount/unmount lifecycle rather than poking the service in isolation.
5
+
6
+ import '@angular/compiler';
7
+ import { Component, inject, signal, type Signal } from '@angular/core';
8
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
9
+ import { mount, unmount } from '@symbiote-native/angular';
10
+ import { flattenStyle } from '@symbiote-native/engine';
11
+ import { installFabric } from '@symbiote-native/test-utils';
12
+ import type { IImageSourceProp } from '@symbiote-native/components';
13
+ import { hide as mockedHide } from 'react-native-bootsplash';
14
+ import type { IHideAnimationResult } from '../../core';
15
+ import { HideAnimationService } from './hide-animation.service';
16
+
17
+ vi.mock('react-native-bootsplash', () => ({
18
+ hide: vi.fn(() => Promise.resolve()),
19
+ isVisible: vi.fn(() => true),
20
+ }));
21
+
22
+ function isPresent<T>(value: unknown): value is T {
23
+ return value !== null && value !== undefined;
24
+ }
25
+
26
+ const FAKE_NATIVE_MODULE = {
27
+ getConstants: () => ({ darkModeEnabled: false }),
28
+ hide: vi.fn(() => Promise.resolve()),
29
+ isVisible: () => true,
30
+ };
31
+
32
+ const registeredNativeModules: Record<string, unknown> = { RNBootSplash: FAKE_NATIVE_MODULE };
33
+
34
+ const ROOT_TAG = 940;
35
+ const fabric = installFabric();
36
+ const tick = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 0));
37
+ const settle = async (): Promise<void> => {
38
+ await tick();
39
+ await tick();
40
+ };
41
+
42
+ const MANIFEST = {
43
+ background: '#ffffff',
44
+ logo: { width: 100, height: 100 },
45
+ brand: { bottom: 40, width: 80, height: 20 },
46
+ };
47
+
48
+ const LOGO_SOURCE: IImageSourceProp = { uri: 'logo.png' };
49
+ const BRAND_SOURCE: IImageSourceProp = { uri: 'brand.png' };
50
+
51
+ let capturedResult: Signal<IHideAnimationResult> | undefined;
52
+ let capturedAnimate: ReturnType<typeof vi.fn> | undefined;
53
+ let capturedReady: ReturnType<typeof signal<boolean>> | undefined;
54
+
55
+ @Component({
56
+ selector: 'symbiote-hide-animation-host',
57
+ standalone: true,
58
+ template: '',
59
+ })
60
+ class HideAnimationHost {
61
+ readonly ready = signal(true);
62
+ readonly animate = vi.fn();
63
+
64
+ readonly hideAnimation = inject(HideAnimationService).connect(() => ({
65
+ manifest: MANIFEST,
66
+ ready: this.ready(),
67
+ logo: LOGO_SOURCE,
68
+ brand: BRAND_SOURCE,
69
+ animate: this.animate,
70
+ }));
71
+
72
+ constructor() {
73
+ capturedResult = this.hideAnimation;
74
+ capturedAnimate = this.animate;
75
+ capturedReady = this.ready;
76
+ }
77
+ }
78
+
79
+ @Component({
80
+ selector: 'symbiote-hide-animation-no-logo-host',
81
+ standalone: true,
82
+ template: '',
83
+ })
84
+ class HideAnimationNoLogoHost {
85
+ readonly animate = vi.fn();
86
+
87
+ readonly hideAnimation = inject(HideAnimationService).connect(() => ({
88
+ manifest: MANIFEST,
89
+ logo: undefined,
90
+ brand: undefined,
91
+ animate: this.animate,
92
+ }));
93
+
94
+ constructor() {
95
+ capturedResult = this.hideAnimation;
96
+ capturedAnimate = this.animate;
97
+ }
98
+ }
99
+
100
+ beforeEach(() => {
101
+ globalThis.__turboModuleProxy = <T>(name: string): T | null => {
102
+ const module = registeredNativeModules[name];
103
+ return isPresent<T>(module) ? module : null;
104
+ };
105
+ capturedResult = undefined;
106
+ capturedAnimate = undefined;
107
+ capturedReady = undefined;
108
+ });
109
+
110
+ afterEach(() => {
111
+ unmount(ROOT_TAG);
112
+ fabric.reset();
113
+ globalThis.__turboModuleProxy = undefined;
114
+ vi.clearAllMocks();
115
+ });
116
+
117
+ describe('HideAnimationService.connect', () => {
118
+ it('reports the container/logo/brand shapes for a config with both logo and brand', async () => {
119
+ mount(ROOT_TAG, HideAnimationHost);
120
+ await settle();
121
+
122
+ const result = capturedResult?.();
123
+ if (result === undefined) throw new Error('hideAnimation signal was not captured');
124
+
125
+ expect(flattenStyle(result.container.style).backgroundColor).toBe(MANIFEST.background);
126
+ expect(result.container.onLayout).toBeTypeOf('function');
127
+
128
+ expect(result.logo.source).toBe(LOGO_SOURCE);
129
+ expect(flattenStyle(result.logo.style)).toEqual({
130
+ width: MANIFEST.logo.width,
131
+ height: MANIFEST.logo.height,
132
+ });
133
+ expect(result.logo.onLoadEnd).toBeTypeOf('function');
134
+
135
+ expect(result.brand.source).toBe(BRAND_SOURCE);
136
+ expect(flattenStyle(result.brand.style)).toMatchObject({
137
+ bottom: MANIFEST.brand.bottom,
138
+ width: MANIFEST.brand.width,
139
+ height: MANIFEST.brand.height,
140
+ });
141
+ expect(result.brand.onLoadEnd).toBeTypeOf('function');
142
+ });
143
+
144
+ it('fires hide() exactly once, only after layout + logo + brand + ready are all satisfied', async () => {
145
+ mount(ROOT_TAG, HideAnimationHost);
146
+ await settle();
147
+
148
+ const result = capturedResult;
149
+ if (result === undefined || capturedAnimate === undefined) {
150
+ throw new Error('hideAnimation signal was not captured');
151
+ }
152
+
153
+ result().container.onLayout();
154
+ expect(mockedHide).not.toHaveBeenCalled();
155
+
156
+ result().logo.onLoadEnd?.();
157
+ expect(mockedHide).not.toHaveBeenCalled();
158
+
159
+ // Brand is the last gate: only once it reports load-end does hide() fire.
160
+ result().brand.onLoadEnd?.();
161
+ await settle();
162
+
163
+ expect(mockedHide).toHaveBeenCalledOnce();
164
+ expect(mockedHide).toHaveBeenCalledWith({ fade: false });
165
+ expect(capturedAnimate).toHaveBeenCalledOnce();
166
+ });
167
+
168
+ it('does not fire hide() again after it has already fired', async () => {
169
+ mount(ROOT_TAG, HideAnimationHost);
170
+ await settle();
171
+
172
+ const result = capturedResult;
173
+ if (result === undefined || capturedReady === undefined) {
174
+ throw new Error('hideAnimation signal was not captured');
175
+ }
176
+
177
+ result().container.onLayout();
178
+ result().logo.onLoadEnd?.();
179
+ result().brand.onLoadEnd?.();
180
+ await settle();
181
+ expect(mockedHide).toHaveBeenCalledOnce();
182
+
183
+ // A later config change (ready flips off then back on) re-runs the effect's updateConfig,
184
+ // but the controller's own animateHasBeenCalled guard keeps hide() from firing twice.
185
+ capturedReady.set(false);
186
+ await settle();
187
+ capturedReady.set(true);
188
+ await settle();
189
+
190
+ expect(mockedHide).toHaveBeenCalledOnce();
191
+ });
192
+
193
+ it('reports source -1 and no onLoadEnd when config.logo is omitted', async () => {
194
+ mount(ROOT_TAG, HideAnimationNoLogoHost);
195
+ await settle();
196
+
197
+ const result = capturedResult?.();
198
+ if (result === undefined) throw new Error('hideAnimation signal was not captured');
199
+
200
+ expect(result.logo.source).toBe(-1);
201
+ expect(result.logo.onLoadEnd).toBeUndefined();
202
+ expect(result.brand.source).toBe(-1);
203
+ expect(result.brand.onLoadEnd).toBeUndefined();
204
+ });
205
+ });
@@ -0,0 +1,35 @@
1
+ import { computed, effect, inject, Injectable, Injector, type Signal } from '@angular/core';
2
+ import {
3
+ computeHideAnimationStyles,
4
+ getHideAnimationConstants,
5
+ HideAnimationController,
6
+ type IHideAnimationConfig,
7
+ type IHideAnimationResult,
8
+ } from '../../core';
9
+
10
+ // Angular twin of React's `useHideAnimation` hook and Vue's `useHideAnimation` composable.
11
+ // Angular has no per-instance hook — state and lifecycle live in DI instead, so `connect()`
12
+ // stands in for the hook's role: call it ONCE (typically from a component's field initializer,
13
+ // inside an injection context) with a GETTER, so it can keep reading the caller's own signals —
14
+ // the same reason Vue's composable also takes a getter rather than a value: there is no
15
+ // re-render/re-setup cycle here to hang a fresh read off of.
16
+ //
17
+ // readonly hideAnimation = inject(HideAnimationService).connect(() => this.config());
18
+ // // template: [style]="hideAnimation().container.style", (layout)="..." etc.
19
+ @Injectable({ providedIn: 'root' })
20
+ export class HideAnimationService {
21
+ // Captured in the constructor (itself always run inside an injection context by Angular's
22
+ // own DI) so `connect()` can create an `effect()` even when called from plain field-initializer
23
+ // code that is not, on its own, an active injection context — mirrors create-tunnel.ts's
24
+ // `TunnelOut`, which passes its own captured `Injector` to `effect()` for the same reason.
25
+ private readonly injector = inject(Injector);
26
+
27
+ connect(getConfig: () => IHideAnimationConfig): Signal<IHideAnimationResult> {
28
+ const controller = new HideAnimationController(getConfig());
29
+ const constants = getHideAnimationConstants();
30
+
31
+ effect(() => controller.updateConfig(getConfig()), { injector: this.injector });
32
+
33
+ return computed(() => computeHideAnimationStyles(getConfig(), constants, controller));
34
+ }
35
+ }
@@ -0,0 +1,111 @@
1
+ import { Platform } from '@symbiote-native/engine';
2
+ import { isEdgeToEdge } from 'react-native-is-edge-to-edge';
3
+ import type { HideAnimationController } from './hide-animation-controller';
4
+ import type {
5
+ IHideAnimationConfig,
6
+ IHideAnimationConstants,
7
+ IHideAnimationImageProps,
8
+ IHideAnimationResult,
9
+ } from './types';
10
+
11
+ // react-native-bootsplash also branches on Platform.OS === 'web' here (react-native-web
12
+ // support); SymbioteNative ships iOS + Android only, so that branch is dropped rather than
13
+ // ported dead.
14
+ const IS_EDGE_TO_EDGE = isEdgeToEdge();
15
+
16
+ const SKIPPED_IMAGE_SOURCE = -1;
17
+
18
+ // Faithful port of react-native-bootsplash's useHideAnimation style computation (its
19
+ // src/index.ts useMemo body): container/logo/brand prop bags the app binds to its own
20
+ // View/Image, unchanged across React/Vue/Angular since none of this touches lifecycle.
21
+ export function computeHideAnimationStyles(
22
+ config: IHideAnimationConfig,
23
+ constants: IHideAnimationConstants,
24
+ controller: HideAnimationController,
25
+ ): IHideAnimationResult {
26
+ const { manifest, statusBarTranslucent, navigationBarTranslucent } = config;
27
+ const {
28
+ darkModeEnabled,
29
+ logoSizeRatio = 1,
30
+ navigationBarHeight = 0,
31
+ statusBarHeight = 0,
32
+ } = constants;
33
+
34
+ const skipLogo = config.logo == null;
35
+ const skipBrand = manifest.brand == null || config.brand == null;
36
+
37
+ const backgroundColor =
38
+ darkModeEnabled && manifest.darkBackground != null
39
+ ? manifest.darkBackground
40
+ : manifest.background;
41
+
42
+ const logoSource = skipLogo
43
+ ? undefined
44
+ : darkModeEnabled && config.darkLogo != null
45
+ ? config.darkLogo
46
+ : config.logo;
47
+
48
+ const brandSource = skipBrand
49
+ ? undefined
50
+ : darkModeEnabled && config.darkBrand != null
51
+ ? config.darkBrand
52
+ : config.brand;
53
+
54
+ const isAndroid = Platform.OS === 'android';
55
+
56
+ const container = {
57
+ style: {
58
+ alignItems: 'center' as const,
59
+ backgroundColor,
60
+ justifyContent: 'center' as const,
61
+ position: 'absolute' as const,
62
+ left: 0,
63
+ right: 0,
64
+ top: 0,
65
+ bottom: 0,
66
+ marginTop:
67
+ isAndroid && !IS_EDGE_TO_EDGE && !(statusBarTranslucent ?? false)
68
+ ? -statusBarHeight
69
+ : undefined,
70
+ marginBottom:
71
+ isAndroid && !IS_EDGE_TO_EDGE && !(navigationBarTranslucent ?? false)
72
+ ? -navigationBarHeight
73
+ : undefined,
74
+ },
75
+ onLayout: controller.onContainerLayout,
76
+ };
77
+
78
+ const logoSizeMultiplier = isAndroid ? logoSizeRatio : 1;
79
+
80
+ const logo: IHideAnimationImageProps =
81
+ logoSource == null
82
+ ? { source: SKIPPED_IMAGE_SOURCE }
83
+ : {
84
+ source: logoSource,
85
+ fadeDuration: 0,
86
+ resizeMode: 'contain',
87
+ style: {
88
+ width: manifest.logo.width * logoSizeMultiplier,
89
+ height: manifest.logo.height * logoSizeMultiplier,
90
+ },
91
+ onLoadEnd: controller.onLogoLoadEnd,
92
+ };
93
+
94
+ const brand: IHideAnimationImageProps =
95
+ brandSource == null || manifest.brand == null
96
+ ? { source: SKIPPED_IMAGE_SOURCE }
97
+ : {
98
+ source: brandSource,
99
+ fadeDuration: 0,
100
+ resizeMode: 'contain',
101
+ style: {
102
+ position: 'absolute',
103
+ bottom: manifest.brand.bottom,
104
+ width: manifest.brand.width,
105
+ height: manifest.brand.height,
106
+ },
107
+ onLoadEnd: controller.onBrandLoadEnd,
108
+ };
109
+
110
+ return { container, logo, brand };
111
+ }
@@ -0,0 +1,70 @@
1
+ import { hide } from './hide';
2
+ import type { IHideAnimationConfig } from './types';
3
+
4
+ type IReadinessState = {
5
+ layoutReady: boolean;
6
+ logoReady: boolean;
7
+ brandReady: boolean;
8
+ userReady: boolean;
9
+ animate: () => void;
10
+ animateHasBeenCalled: boolean;
11
+ };
12
+
13
+ // Faithful port of react-native-bootsplash's useHideAnimation readiness gate (its
14
+ // src/index.ts): hide() fires exactly once, after layout + both images (if requested) +
15
+ // the caller all report ready, then the caller's own fade-out `animate()` runs. logoReady/
16
+ // brandReady are captured ONCE at construction (mirrors the original's useRef factory,
17
+ // evaluated only on first render) — a config that later drops its logo/brand source does
18
+ // NOT retroactively flip readiness back on, only updateConfig's animate/userReady do.
19
+ export class HideAnimationController {
20
+ private readonly readiness: IReadinessState;
21
+
22
+ constructor(config: IHideAnimationConfig) {
23
+ this.readiness = {
24
+ layoutReady: false,
25
+ logoReady: config.logo == null,
26
+ brandReady: config.manifest.brand == null || config.brand == null,
27
+ userReady: config.ready ?? true,
28
+ animate: config.animate,
29
+ animateHasBeenCalled: false,
30
+ };
31
+ }
32
+
33
+ updateConfig(config: IHideAnimationConfig): void {
34
+ this.readiness.animate = config.animate;
35
+ this.readiness.userReady = config.ready ?? true;
36
+ this.maybeRunAnimate();
37
+ }
38
+
39
+ readonly onContainerLayout = (): void => {
40
+ this.readiness.layoutReady = true;
41
+ this.maybeRunAnimate();
42
+ };
43
+
44
+ readonly onLogoLoadEnd = (): void => {
45
+ this.readiness.logoReady = true;
46
+ this.maybeRunAnimate();
47
+ };
48
+
49
+ readonly onBrandLoadEnd = (): void => {
50
+ this.readiness.brandReady = true;
51
+ this.maybeRunAnimate();
52
+ };
53
+
54
+ private maybeRunAnimate(): void {
55
+ const state = this.readiness;
56
+
57
+ if (
58
+ state.layoutReady &&
59
+ state.logoReady &&
60
+ state.brandReady &&
61
+ state.userReady &&
62
+ !state.animateHasBeenCalled
63
+ ) {
64
+ state.animateHasBeenCalled = true;
65
+ hide({ fade: false })
66
+ .then(() => state.animate())
67
+ .catch(() => {});
68
+ }
69
+ }
70
+ }
@@ -0,0 +1,4 @@
1
+ // hide()/isVisible() carry zero React dependency upstream (react-native-bootsplash's own
2
+ // src/index.ts calls straight into its TurboModule) — framework-agnostic as-is, so every
3
+ // adapter re-exports this verbatim rather than re-wrapping it.
4
+ export { hide, isVisible } from 'react-native-bootsplash';
@@ -0,0 +1,13 @@
1
+ export { hide, isVisible } from './hide';
2
+ export { HideAnimationController } from './hide-animation-controller';
3
+ export { computeHideAnimationStyles } from './compute-hide-animation-styles';
4
+ export { getHideAnimationConstants } from './native-module';
5
+ export type {
6
+ IHideConfig,
7
+ IManifest,
8
+ IHideAnimationConfig,
9
+ IHideAnimationContainerProps,
10
+ IHideAnimationImageProps,
11
+ IHideAnimationResult,
12
+ IHideAnimationConstants,
13
+ } from './types';
@@ -0,0 +1,19 @@
1
+ // react-native-bootsplash's package.json ships an `exports` map that only opens `.`,
2
+ // `./expo`, `./package.json`, `./app.plugin.js` — its internal TurboModule spec
3
+ // (dist/commonjs/specs/NativeRNBootSplash) is NOT a published subpath, so a deep import
4
+ // would throw ERR_PACKAGE_PATH_NOT_EXPORTED. getEnforcingNativeModule reads the same
5
+ // global TurboModule proxy the spec itself reads (by name), sidestepping the exports map
6
+ // entirely — this is how we reach getConstants(), which the package's public JS API
7
+ // (hide/isVisible/useHideAnimation) never exposes directly.
8
+ import { getEnforcingNativeModule } from '@symbiote-native/engine';
9
+ import type { IHideAnimationConstants } from './types';
10
+
11
+ const RN_BOOT_SPLASH_MODULE_NAME = 'RNBootSplash';
12
+
13
+ type IRNBootSplashSpec = {
14
+ getConstants(): IHideAnimationConstants;
15
+ };
16
+
17
+ export function getHideAnimationConstants(): IHideAnimationConstants {
18
+ return getEnforcingNativeModule<IRNBootSplashSpec>(RN_BOOT_SPLASH_MODULE_NAME).getConstants();
19
+ }
@@ -0,0 +1,62 @@
1
+ import type { IImageSourceProp, IResizeMode } from '@symbiote-native/components';
2
+ import type { IStyleProp, IViewStyle } from '@symbiote-native/engine';
3
+
4
+ export type IHideConfig = {
5
+ fade?: boolean;
6
+ };
7
+
8
+ export type IManifest = {
9
+ background: string;
10
+ darkBackground?: string;
11
+ logo: {
12
+ width: number;
13
+ height: number;
14
+ };
15
+ brand?: {
16
+ bottom: number;
17
+ width: number;
18
+ height: number;
19
+ };
20
+ };
21
+
22
+ export type IHideAnimationConfig = {
23
+ manifest: IManifest;
24
+ ready?: boolean;
25
+
26
+ logo?: IImageSourceProp;
27
+ darkLogo?: IImageSourceProp;
28
+ brand?: IImageSourceProp;
29
+ darkBrand?: IImageSourceProp;
30
+
31
+ animate: () => void;
32
+
33
+ statusBarTranslucent?: boolean;
34
+ navigationBarTranslucent?: boolean;
35
+ };
36
+
37
+ export type IHideAnimationContainerProps = {
38
+ style: IStyleProp<IViewStyle>;
39
+ onLayout: () => void;
40
+ };
41
+
42
+ export type IHideAnimationImageProps = {
43
+ source: IImageSourceProp;
44
+ fadeDuration?: number;
45
+ resizeMode?: IResizeMode;
46
+ style?: IStyleProp<IViewStyle>;
47
+ onLoadEnd?: () => void;
48
+ };
49
+
50
+ export type IHideAnimationResult = {
51
+ container: IHideAnimationContainerProps;
52
+ logo: IHideAnimationImageProps;
53
+ brand: IHideAnimationImageProps;
54
+ };
55
+
56
+ // Mirrors react-native-bootsplash's NativeRNBootSplash Spec.getConstants() return shape.
57
+ export type IHideAnimationConstants = {
58
+ darkModeEnabled: boolean;
59
+ logoSizeRatio?: number;
60
+ navigationBarHeight?: number;
61
+ statusBarHeight?: number;
62
+ };
@@ -0,0 +1,114 @@
1
+ // Co-located React-driven test (ADR 0025) for useHideAnimation. Mocks react-native-bootsplash's
2
+ // hide()/isVisible() (imported straight from the npm package by core/hide.ts) and installs a
3
+ // fake __turboModuleProxy so getHideAnimationConstants() resolves without a real native host.
4
+
5
+ import { createElement, type ReactElement } from 'react';
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
7
+ import { mount, unmount, View } from '@symbiote-native/react';
8
+ import { installFabric } from '@symbiote-native/test-utils';
9
+ import { useHideAnimation } from './use-hide-animation';
10
+ import type { IHideAnimationConfig, IHideAnimationResult, IManifest } from '../../core';
11
+
12
+ vi.mock('react-native-bootsplash', () => ({
13
+ hide: vi.fn(() => Promise.resolve()),
14
+ isVisible: vi.fn(() => true),
15
+ }));
16
+
17
+ const ROOT_TAG = 900;
18
+
19
+ const FAKE_NATIVE_MODULE = { getConstants: () => ({ darkModeEnabled: false }) };
20
+
21
+ function isPresent<T>(value: unknown): value is T {
22
+ return value !== null && value !== undefined;
23
+ }
24
+
25
+ Object.assign(globalThis, {
26
+ __turboModuleProxy: <T,>(name: string): T | null =>
27
+ name === 'RNBootSplash' && isPresent<T>(FAKE_NATIVE_MODULE) ? FAKE_NATIVE_MODULE : null,
28
+ });
29
+
30
+ const MANIFEST: IManifest = { background: '#fff', logo: { width: 100, height: 100 } };
31
+
32
+ const results: IHideAnimationResult[] = [];
33
+
34
+ function Probe(props: { config: IHideAnimationConfig }): ReactElement {
35
+ results.push(useHideAnimation(props.config));
36
+ return createElement(View);
37
+ }
38
+
39
+ const fabric = installFabric();
40
+
41
+ beforeEach(() => {
42
+ fabric.reset();
43
+ results.length = 0;
44
+ vi.clearAllMocks();
45
+ });
46
+
47
+ afterEach(() => unmount(ROOT_TAG));
48
+
49
+ describe('useHideAnimation', () => {
50
+ it('reports the skip sentinel and no onLoadEnd when logo is omitted', () => {
51
+ mount(ROOT_TAG, createElement(Probe, { config: { manifest: MANIFEST, animate: () => {} } }));
52
+
53
+ const last = results[results.length - 1];
54
+ expect(last.logo.source).toBe(-1);
55
+ expect(last.logo.onLoadEnd).toBeUndefined();
56
+ });
57
+
58
+ it('returns the full logo/brand shape when both are provided', () => {
59
+ const config: IHideAnimationConfig = {
60
+ manifest: { ...MANIFEST, brand: { bottom: 20, width: 60, height: 20 } },
61
+ logo: 123,
62
+ brand: 456,
63
+ animate: () => {},
64
+ };
65
+
66
+ mount(ROOT_TAG, createElement(Probe, { config }));
67
+
68
+ const last = results[results.length - 1];
69
+ expect(last.logo.source).toBe(123);
70
+ expect(last.logo.resizeMode).toBe('contain');
71
+ expect(last.brand.source).toBe(456);
72
+ expect(last.brand.resizeMode).toBe('contain');
73
+ });
74
+
75
+ it('hides exactly once, only after layout + logo load-end both resolve', async () => {
76
+ const { hide } = await import('react-native-bootsplash');
77
+ let animateCalls = 0;
78
+ const config: IHideAnimationConfig = {
79
+ manifest: MANIFEST,
80
+ logo: 1,
81
+ ready: true,
82
+ animate: () => {
83
+ animateCalls += 1;
84
+ },
85
+ };
86
+
87
+ mount(ROOT_TAG, createElement(Probe, { config }));
88
+ const result = results[results.length - 1];
89
+
90
+ expect(hide).not.toHaveBeenCalled();
91
+ result.container.onLayout();
92
+ expect(hide).not.toHaveBeenCalled();
93
+
94
+ result.logo.onLoadEnd?.();
95
+
96
+ expect(hide).toHaveBeenCalledTimes(1);
97
+ expect(hide).toHaveBeenCalledWith({ fade: false });
98
+ await vi.waitFor(() => expect(animateCalls).toBe(1));
99
+ });
100
+
101
+ it('does not hide again once already triggered', async () => {
102
+ const { hide } = await import('react-native-bootsplash');
103
+ const config: IHideAnimationConfig = { manifest: MANIFEST, ready: true, animate: () => {} };
104
+
105
+ mount(ROOT_TAG, createElement(Probe, { config }));
106
+ const result = results[results.length - 1];
107
+
108
+ result.container.onLayout();
109
+ await vi.waitFor(() => expect(hide).toHaveBeenCalledTimes(1));
110
+
111
+ result.container.onLayout();
112
+ expect(hide).toHaveBeenCalledTimes(1);
113
+ });
114
+ });