@teardown/dev-client 2.0.70 → 2.0.72

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teardown/dev-client",
3
- "version": "2.0.70",
3
+ "version": "2.0.72",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -34,7 +34,7 @@
34
34
  },
35
35
  "devDependencies": {
36
36
  "@biomejs/biome": "2.3.11",
37
- "@teardown/tsconfig": "2.0.70",
37
+ "@teardown/tsconfig": "2.0.72",
38
38
  "@types/bun": "1.3.5",
39
39
  "@types/react": "~19.1.0",
40
40
  "react": "19.1.0",
@@ -0,0 +1,448 @@
1
+ /**
2
+ * BundlerWaitingOverlay Component
3
+ *
4
+ * Full-screen overlay shown when the Metro bundler is not connected.
5
+ * Provides visual feedback while waiting for bundler connection,
6
+ * similar to Expo's dev launcher experience.
7
+ */
8
+
9
+ import type React from "react";
10
+ import { useCallback, useEffect, useRef, useState } from "react";
11
+ import {
12
+ ActivityIndicator,
13
+ Animated,
14
+ Keyboard,
15
+ KeyboardAvoidingView,
16
+ Platform,
17
+ StyleSheet,
18
+ Text,
19
+ TextInput,
20
+ type TextStyle,
21
+ TouchableOpacity,
22
+ View,
23
+ type ViewStyle,
24
+ } from "react-native";
25
+ import type { BundlerStatus } from "../../types";
26
+ import { isValidBundlerUrl } from "../../utils/bundler-url";
27
+
28
+ /**
29
+ * BundlerWaitingOverlay component props
30
+ */
31
+ export interface BundlerWaitingOverlayProps {
32
+ /** Current bundler status */
33
+ bundlerStatus: BundlerStatus;
34
+
35
+ /** Expected bundler URL */
36
+ bundlerUrl: string | null;
37
+
38
+ /** Callback to retry connection */
39
+ onRetry?: () => void;
40
+
41
+ /** Callback when user enters custom URL */
42
+ onCustomUrl?: (url: string) => void;
43
+
44
+ /** Whether overlay is visible */
45
+ visible: boolean;
46
+
47
+ /** Test ID for testing */
48
+ testID?: string;
49
+ }
50
+
51
+ /**
52
+ * Connection state for display
53
+ */
54
+ type ConnectionState = "connecting" | "failed" | "connected";
55
+
56
+ /**
57
+ * Get connection state from bundler status
58
+ */
59
+ function getConnectionState(status: BundlerStatus): ConnectionState {
60
+ if (status.connected) {
61
+ return "connected";
62
+ }
63
+ if (status.error) {
64
+ return "failed";
65
+ }
66
+ return "connecting";
67
+ }
68
+
69
+ /**
70
+ * BundlerWaitingOverlay component
71
+ *
72
+ * Shows a full-screen overlay while waiting for Metro bundler connection.
73
+ * Features:
74
+ * - Animated spinner during connection
75
+ * - Error message display
76
+ * - Retry button
77
+ * - Manual URL entry option
78
+ * - Auto-dismisses when connected
79
+ *
80
+ * @example
81
+ * ```tsx
82
+ * <BundlerWaitingOverlay
83
+ * bundlerStatus={bundlerStatus}
84
+ * bundlerUrl="http://localhost:8081"
85
+ * onRetry={() => checkConnection()}
86
+ * visible={!bundlerStatus.connected}
87
+ * />
88
+ * ```
89
+ */
90
+ export function BundlerWaitingOverlay(props: BundlerWaitingOverlayProps): React.JSX.Element | null {
91
+ const { bundlerStatus, bundlerUrl, onRetry, onCustomUrl, visible, testID = "bundler-waiting-overlay" } = props;
92
+
93
+ // Animation for fade in/out
94
+ const fadeAnim = useRef(new Animated.Value(0)).current;
95
+
96
+ // Track if overlay should be rendered (for fade out animation)
97
+ const [isRendered, setIsRendered] = useState(visible);
98
+
99
+ // Manual URL input state
100
+ const [showUrlInput, setShowUrlInput] = useState(false);
101
+ const [customUrl, setCustomUrl] = useState("");
102
+ const [urlError, setUrlError] = useState<string | null>(null);
103
+
104
+ // Connection state
105
+ const connectionState = getConnectionState(bundlerStatus);
106
+
107
+ // Fade in/out animation
108
+ useEffect(() => {
109
+ if (visible) {
110
+ setIsRendered(true);
111
+ }
112
+
113
+ Animated.timing(fadeAnim, {
114
+ toValue: visible ? 1 : 0,
115
+ duration: 300,
116
+ useNativeDriver: true,
117
+ }).start(() => {
118
+ // After fade out animation completes, stop rendering
119
+ if (!visible) {
120
+ setIsRendered(false);
121
+ }
122
+ });
123
+ }, [visible, fadeAnim]);
124
+
125
+ // Handle retry
126
+ const handleRetry = useCallback(() => {
127
+ onRetry?.();
128
+ }, [onRetry]);
129
+
130
+ // Handle custom URL submission
131
+ const handleCustomUrlSubmit = useCallback(() => {
132
+ if (!customUrl.trim()) {
133
+ setUrlError("Please enter a URL");
134
+ return;
135
+ }
136
+
137
+ // Add http:// if not present
138
+ let url = customUrl.trim();
139
+ if (!url.includes("://")) {
140
+ url = `http://${url}`;
141
+ }
142
+
143
+ if (!isValidBundlerUrl(url)) {
144
+ setUrlError("Invalid URL format");
145
+ return;
146
+ }
147
+
148
+ setUrlError(null);
149
+ Keyboard.dismiss();
150
+ onCustomUrl?.(url);
151
+ }, [customUrl, onCustomUrl]);
152
+
153
+ // Handle URL tap - for future clipboard support
154
+ const handleUrlTap = useCallback(() => {
155
+ // Clipboard functionality can be added when @react-native-clipboard/clipboard is available
156
+ // For now, this is a no-op but provides touch feedback
157
+ }, []);
158
+
159
+ // Toggle URL input section
160
+ const toggleUrlInput = useCallback(() => {
161
+ setShowUrlInput((prev) => !prev);
162
+ setUrlError(null);
163
+ }, []);
164
+
165
+ // Don't render if not visible and animation complete
166
+ if (!isRendered) {
167
+ return null;
168
+ }
169
+
170
+ // Don't show in production
171
+ if (!__DEV__) {
172
+ return null;
173
+ }
174
+
175
+ return (
176
+ <Animated.View
177
+ style={[styles.container, { opacity: fadeAnim }]}
178
+ testID={testID}
179
+ accessibilityLabel="Bundler connection overlay"
180
+ accessibilityRole="alert"
181
+ >
182
+ <KeyboardAvoidingView behavior={Platform.OS === "ios" ? "padding" : "height"} style={styles.keyboardView}>
183
+ <View style={styles.content}>
184
+ {/* Logo/Icon */}
185
+ <View style={styles.logoContainer}>
186
+ <Text style={styles.logoText}>T</Text>
187
+ </View>
188
+
189
+ {/* Status */}
190
+ <View style={styles.statusContainer}>
191
+ {connectionState === "connecting" && (
192
+ <>
193
+ <ActivityIndicator size="large" color="#007aff" accessibilityLabel="Connecting" />
194
+ <Text style={styles.statusText}>Connecting to bundler...</Text>
195
+ </>
196
+ )}
197
+
198
+ {connectionState === "failed" && (
199
+ <>
200
+ <Text style={styles.errorIcon}>!</Text>
201
+ <Text style={styles.statusText}>Connection failed</Text>
202
+ <Text style={styles.errorText}>{bundlerStatus.error}</Text>
203
+ </>
204
+ )}
205
+
206
+ {connectionState === "connected" && (
207
+ <>
208
+ <Text style={styles.successIcon}>✓</Text>
209
+ <Text style={styles.statusText}>Connected</Text>
210
+ </>
211
+ )}
212
+ </View>
213
+
214
+ {/* Bundler URL */}
215
+ {bundlerUrl && connectionState !== "connected" && (
216
+ <TouchableOpacity
217
+ style={styles.urlContainer}
218
+ onPress={handleUrlTap}
219
+ activeOpacity={0.7}
220
+ accessibilityLabel={`Bundler URL: ${bundlerUrl}`}
221
+ >
222
+ <Text style={styles.urlLabel}>Waiting for:</Text>
223
+ <Text style={styles.urlText}>{bundlerUrl}</Text>
224
+ </TouchableOpacity>
225
+ )}
226
+
227
+ {/* Actions */}
228
+ {connectionState !== "connected" && (
229
+ <View style={styles.actions}>
230
+ {/* Retry Button */}
231
+ <TouchableOpacity
232
+ style={styles.retryButton}
233
+ onPress={handleRetry}
234
+ activeOpacity={0.7}
235
+ accessibilityLabel="Retry connection"
236
+ accessibilityRole="button"
237
+ >
238
+ <Text style={styles.retryButtonText}>Retry Connection</Text>
239
+ </TouchableOpacity>
240
+
241
+ {/* Manual URL Toggle */}
242
+ <TouchableOpacity
243
+ style={styles.toggleButton}
244
+ onPress={toggleUrlInput}
245
+ activeOpacity={0.7}
246
+ accessibilityLabel={showUrlInput ? "Hide URL input" : "Enter URL manually"}
247
+ accessibilityRole="button"
248
+ >
249
+ <Text style={styles.toggleButtonText}>{showUrlInput ? "▼" : "▶"} Enter URL manually</Text>
250
+ </TouchableOpacity>
251
+
252
+ {/* URL Input Section */}
253
+ {showUrlInput && (
254
+ <View style={styles.urlInputContainer}>
255
+ <TextInput
256
+ style={[styles.urlInput, urlError && styles.urlInputError]}
257
+ value={customUrl}
258
+ onChangeText={(text) => {
259
+ setCustomUrl(text);
260
+ setUrlError(null);
261
+ }}
262
+ placeholder="http://10.0.0.25:8081"
263
+ placeholderTextColor="#666666"
264
+ autoCapitalize="none"
265
+ autoCorrect={false}
266
+ keyboardType="url"
267
+ returnKeyType="go"
268
+ onSubmitEditing={handleCustomUrlSubmit}
269
+ accessibilityLabel="Custom bundler URL"
270
+ />
271
+ {urlError && <Text style={styles.urlErrorText}>{urlError}</Text>}
272
+ <TouchableOpacity
273
+ style={[styles.connectButton, !customUrl.trim() && styles.connectButtonDisabled]}
274
+ onPress={handleCustomUrlSubmit}
275
+ disabled={!customUrl.trim()}
276
+ activeOpacity={0.7}
277
+ accessibilityLabel="Connect to custom URL"
278
+ accessibilityRole="button"
279
+ >
280
+ <Text style={styles.connectButtonText}>Connect</Text>
281
+ </TouchableOpacity>
282
+ </View>
283
+ )}
284
+ </View>
285
+ )}
286
+ </View>
287
+ </KeyboardAvoidingView>
288
+ </Animated.View>
289
+ );
290
+ }
291
+
292
+ const styles = StyleSheet.create({
293
+ container: {
294
+ ...StyleSheet.absoluteFillObject,
295
+ backgroundColor: "#1a1a1a",
296
+ zIndex: 10001,
297
+ } as ViewStyle,
298
+ keyboardView: {
299
+ flex: 1,
300
+ } as ViewStyle,
301
+ content: {
302
+ flex: 1,
303
+ justifyContent: "center",
304
+ alignItems: "center",
305
+ padding: 24,
306
+ } as ViewStyle,
307
+
308
+ // Logo
309
+ logoContainer: {
310
+ width: 80,
311
+ height: 80,
312
+ borderRadius: 20,
313
+ backgroundColor: "#2a2a2a",
314
+ justifyContent: "center",
315
+ alignItems: "center",
316
+ marginBottom: 32,
317
+ } as ViewStyle,
318
+ logoText: {
319
+ fontSize: 40,
320
+ fontWeight: "bold",
321
+ color: "#007aff",
322
+ } as TextStyle,
323
+
324
+ // Status
325
+ statusContainer: {
326
+ alignItems: "center",
327
+ marginBottom: 24,
328
+ } as ViewStyle,
329
+ statusText: {
330
+ fontSize: 18,
331
+ fontWeight: "600",
332
+ color: "#ffffff",
333
+ marginTop: 16,
334
+ } as TextStyle,
335
+ errorIcon: {
336
+ fontSize: 32,
337
+ fontWeight: "bold",
338
+ color: "#ff3b30",
339
+ width: 48,
340
+ height: 48,
341
+ lineHeight: 48,
342
+ textAlign: "center",
343
+ backgroundColor: "#3a1010",
344
+ borderRadius: 24,
345
+ overflow: "hidden",
346
+ } as TextStyle,
347
+ successIcon: {
348
+ fontSize: 32,
349
+ fontWeight: "bold",
350
+ color: "#34c759",
351
+ } as TextStyle,
352
+ errorText: {
353
+ fontSize: 14,
354
+ color: "#ff6b6b",
355
+ marginTop: 8,
356
+ textAlign: "center",
357
+ maxWidth: 280,
358
+ } as TextStyle,
359
+
360
+ // URL display
361
+ urlContainer: {
362
+ backgroundColor: "#2a2a2a",
363
+ borderRadius: 12,
364
+ padding: 16,
365
+ alignItems: "center",
366
+ marginBottom: 24,
367
+ minWidth: 280,
368
+ } as ViewStyle,
369
+ urlLabel: {
370
+ fontSize: 12,
371
+ color: "#888888",
372
+ marginBottom: 4,
373
+ textTransform: "uppercase",
374
+ } as TextStyle,
375
+ urlText: {
376
+ fontSize: 16,
377
+ color: "#ffffff",
378
+ fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
379
+ } as TextStyle,
380
+
381
+ // Actions
382
+ actions: {
383
+ alignItems: "center",
384
+ width: "100%",
385
+ maxWidth: 320,
386
+ } as ViewStyle,
387
+ retryButton: {
388
+ backgroundColor: "#007aff",
389
+ paddingVertical: 14,
390
+ paddingHorizontal: 32,
391
+ borderRadius: 12,
392
+ minWidth: 200,
393
+ alignItems: "center",
394
+ marginBottom: 16,
395
+ } as ViewStyle,
396
+ retryButtonText: {
397
+ color: "#ffffff",
398
+ fontSize: 16,
399
+ fontWeight: "600",
400
+ } as TextStyle,
401
+ toggleButton: {
402
+ paddingVertical: 12,
403
+ } as ViewStyle,
404
+ toggleButtonText: {
405
+ color: "#888888",
406
+ fontSize: 14,
407
+ } as TextStyle,
408
+
409
+ // URL Input
410
+ urlInputContainer: {
411
+ width: "100%",
412
+ marginTop: 12,
413
+ } as ViewStyle,
414
+ urlInput: {
415
+ backgroundColor: "#2a2a2a",
416
+ borderRadius: 8,
417
+ paddingHorizontal: 16,
418
+ paddingVertical: 12,
419
+ fontSize: 16,
420
+ color: "#ffffff",
421
+ borderWidth: 1,
422
+ borderColor: "#3a3a3a",
423
+ } as TextStyle,
424
+ urlInputError: {
425
+ borderColor: "#ff3b30",
426
+ } as ViewStyle,
427
+ urlErrorText: {
428
+ color: "#ff3b30",
429
+ fontSize: 12,
430
+ marginTop: 4,
431
+ marginLeft: 4,
432
+ } as TextStyle,
433
+ connectButton: {
434
+ backgroundColor: "#333333",
435
+ paddingVertical: 12,
436
+ borderRadius: 8,
437
+ alignItems: "center",
438
+ marginTop: 12,
439
+ } as ViewStyle,
440
+ connectButtonDisabled: {
441
+ opacity: 0.5,
442
+ } as ViewStyle,
443
+ connectButtonText: {
444
+ color: "#ffffff",
445
+ fontSize: 16,
446
+ fontWeight: "600",
447
+ } as TextStyle,
448
+ });
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Bundler Waiting Overlay
3
+ *
4
+ * Full-screen overlay shown when Metro bundler is not connected.
5
+ */
6
+
7
+ export { BundlerWaitingOverlay, type BundlerWaitingOverlayProps } from "./bundler-waiting-overlay";
@@ -2,6 +2,7 @@
2
2
  * Component exports
3
3
  */
4
4
 
5
+ export { BundlerWaitingOverlay, type BundlerWaitingOverlayProps } from "./bundler-waiting";
5
6
  export { DevMenu, type DevMenuProps } from "./dev-menu";
6
7
  export { ErrorOverlay, type ErrorOverlayProps } from "./error-overlay";
7
8
  export { SplashScreen, type SplashScreenProps } from "./splash-screen";
@@ -2,13 +2,16 @@
2
2
  * DevClientProvider
3
3
  *
4
4
  * Root provider component that wraps the application and provides
5
- * development client functionality including dev menu and splash screen.
5
+ * development client functionality including dev menu, splash screen,
6
+ * and bundler connection overlay.
6
7
  */
7
8
 
8
- import React, { createContext, type PropsWithChildren, useContext, useMemo } from "react";
9
+ import React, { createContext, type PropsWithChildren, useCallback, useContext, useMemo, useState } from "react";
10
+ import { BundlerWaitingOverlay } from "./components/bundler-waiting";
9
11
  import { DevMenu } from "./components/dev-menu";
10
12
  import { SplashScreen } from "./components/splash-screen";
11
13
  import { useBundlerStatus } from "./hooks/use-bundler-status";
14
+ import { useDeepLinkBundler } from "./hooks/use-deep-link-bundler";
12
15
  import { useDevMenu } from "./hooks/use-dev-menu";
13
16
  import { useSplashScreen } from "./hooks/use-splash-screen";
14
17
  import type { DevClientConfig, DevClientContextValue } from "./types";
@@ -45,6 +48,9 @@ export interface DevClientProviderProps extends PropsWithChildren {
45
48
 
46
49
  /** Whether to enable dev menu (default: true in dev) */
47
50
  enableDevMenu?: boolean;
51
+
52
+ /** Whether to show bundler waiting overlay when not connected (default: true in dev) */
53
+ showBundlerWaiting?: boolean;
48
54
  }
49
55
 
50
56
  /**
@@ -74,7 +80,19 @@ export interface DevClientProviderProps extends PropsWithChildren {
74
80
  * ```
75
81
  */
76
82
  export function DevClientProvider(props: DevClientProviderProps): React.JSX.Element {
77
- const { children, config: userConfig, showSplash = __DEV__, enableDevMenu = __DEV__ } = props;
83
+ const {
84
+ children,
85
+ config: userConfig,
86
+ showSplash = __DEV__,
87
+ enableDevMenu = __DEV__,
88
+ showBundlerWaiting = __DEV__,
89
+ } = props;
90
+
91
+ // Deep link bundler URL (if app was launched with dev deep link)
92
+ const { bundlerUrl: deepLinkBundlerUrl } = useDeepLinkBundler();
93
+
94
+ // Custom bundler URL override (from manual entry in overlay)
95
+ const [customBundlerUrl, setCustomBundlerUrl] = useState<string | null>(null);
78
96
 
79
97
  // Merge config with defaults
80
98
  const config: DevClientConfig = useMemo(
@@ -89,12 +107,31 @@ export function DevClientProvider(props: DevClientProviderProps): React.JSX.Elem
89
107
  [userConfig]
90
108
  );
91
109
 
110
+ // Effective bundler URL: custom > deep link > config
111
+ const effectiveBundlerUrl = customBundlerUrl ?? deepLinkBundlerUrl ?? config.bundlerUrl;
112
+
113
+ // Bundler status - track connection retry trigger
114
+ const [retryTrigger, setRetryTrigger] = useState(0);
115
+
92
116
  // Bundler status
93
117
  const bundlerStatus = useBundlerStatus({
94
- bundlerUrl: config.bundlerUrl,
118
+ bundlerUrl: effectiveBundlerUrl,
95
119
  enablePolling: __DEV__,
120
+ // Force re-check when retry is triggered
121
+ pollInterval: retryTrigger > 0 ? 2000 : 5000,
96
122
  });
97
123
 
124
+ // Handle retry connection
125
+ const handleRetryConnection = useCallback(() => {
126
+ setRetryTrigger((prev) => prev + 1);
127
+ }, []);
128
+
129
+ // Handle custom URL from overlay
130
+ const handleCustomUrl = useCallback((url: string) => {
131
+ setCustomBundlerUrl(url);
132
+ setRetryTrigger((prev) => prev + 1);
133
+ }, []);
134
+
98
135
  // Dev menu
99
136
  const devMenu = useDevMenu({
100
137
  enableShakeGesture: enableDevMenu && config.enableShakeGesture,
@@ -106,6 +143,9 @@ export function DevClientProvider(props: DevClientProviderProps): React.JSX.Elem
106
143
  config: config.splash,
107
144
  });
108
145
 
146
+ // Show bundler waiting overlay when not connected in dev mode
147
+ const showWaitingOverlay = showBundlerWaiting && __DEV__ && !bundlerStatus.connected;
148
+
109
149
  // Context value
110
150
  const contextValue: DevClientContextValue = useMemo(
111
151
  () => ({
@@ -153,6 +193,18 @@ export function DevClientProvider(props: DevClientProviderProps): React.JSX.Elem
153
193
  testID="teardown-splash-screen"
154
194
  />
155
195
  )}
196
+
197
+ {/* Bundler Waiting Overlay - shown when not connected in dev */}
198
+ {showBundlerWaiting && (
199
+ <BundlerWaitingOverlay
200
+ bundlerStatus={bundlerStatus}
201
+ bundlerUrl={effectiveBundlerUrl ?? null}
202
+ onRetry={handleRetryConnection}
203
+ onCustomUrl={handleCustomUrl}
204
+ visible={showWaitingOverlay}
205
+ testID="teardown-bundler-waiting"
206
+ />
207
+ )}
156
208
  </DevClientContext.Provider>
157
209
  );
158
210
  }
@@ -8,6 +8,11 @@ export {
8
8
  useBundlerStatus,
9
9
  } from "./use-bundler-status";
10
10
 
11
+ export {
12
+ type UseDeepLinkBundlerReturn,
13
+ useDeepLinkBundler,
14
+ } from "./use-deep-link-bundler";
15
+
11
16
  export {
12
17
  createInitialDevMenuState,
13
18
  type DefaultMenuItemHandlers,
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Deep Link Bundler URL Hook
3
+ *
4
+ * Parses bundler URL from deep link when app is launched
5
+ * with a dev deep link (e.g., myapp://dev?bundler=http://localhost:8081)
6
+ */
7
+
8
+ import { useCallback, useEffect, useState } from "react";
9
+ import { Linking } from "react-native";
10
+
11
+ /**
12
+ * Parse bundler URL from a deep link
13
+ *
14
+ * @param url - Deep link URL
15
+ * @returns Bundler URL or null if not found
16
+ */
17
+ function parseBundlerFromUrl(url: string | null): string | null {
18
+ if (!url) {
19
+ return null;
20
+ }
21
+
22
+ try {
23
+ // Handle custom scheme URLs by replacing scheme with http for URL parsing
24
+ // e.g., myapp://dev?bundler=... -> http://dev?bundler=...
25
+ const normalizedUrl = url.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "http://");
26
+ const parsed = new URL(normalizedUrl);
27
+
28
+ // Check if this is a dev deep link
29
+ const pathname = parsed.hostname + parsed.pathname;
30
+ if (!pathname.includes("dev")) {
31
+ return null;
32
+ }
33
+
34
+ // Get bundler param
35
+ const bundler = parsed.searchParams.get("bundler");
36
+ if (bundler) {
37
+ return decodeURIComponent(bundler);
38
+ }
39
+
40
+ return null;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Return type for the hook
48
+ */
49
+ export interface UseDeepLinkBundlerReturn {
50
+ /** Bundler URL from deep link, if present */
51
+ bundlerUrl: string | null;
52
+ /** Whether initial URL has been checked */
53
+ isReady: boolean;
54
+ /** Clear the bundler URL (e.g., after connecting) */
55
+ clear: () => void;
56
+ }
57
+
58
+ /**
59
+ * Hook to get bundler URL from deep link
60
+ *
61
+ * Listens for the initial URL when the app launches and subsequent
62
+ * URL events to extract the bundler parameter from dev deep links.
63
+ *
64
+ * @returns Object with bundlerUrl and ready state
65
+ *
66
+ * @example
67
+ * ```tsx
68
+ * function MyApp() {
69
+ * const { bundlerUrl, isReady } = useDeepLinkBundler();
70
+ *
71
+ * useEffect(() => {
72
+ * if (bundlerUrl) {
73
+ * console.log('Bundler URL from deep link:', bundlerUrl);
74
+ * }
75
+ * }, [bundlerUrl]);
76
+ *
77
+ * if (!isReady) {
78
+ * return null; // Wait for initial URL check
79
+ * }
80
+ *
81
+ * return <App />;
82
+ * }
83
+ * ```
84
+ */
85
+ export function useDeepLinkBundler(): UseDeepLinkBundlerReturn {
86
+ const [bundlerUrl, setBundlerUrl] = useState<string | null>(null);
87
+ const [isReady, setIsReady] = useState(false);
88
+
89
+ // Clear the bundler URL
90
+ const clear = useCallback(() => {
91
+ setBundlerUrl(null);
92
+ }, []);
93
+
94
+ // Check initial URL when app launches
95
+ useEffect(() => {
96
+ let mounted = true;
97
+
98
+ const checkInitialUrl = async () => {
99
+ try {
100
+ const initialUrl = await Linking.getInitialURL();
101
+ if (mounted) {
102
+ const parsed = parseBundlerFromUrl(initialUrl);
103
+ if (parsed) {
104
+ setBundlerUrl(parsed);
105
+ }
106
+ setIsReady(true);
107
+ }
108
+ } catch (error) {
109
+ if (mounted) {
110
+ setIsReady(true);
111
+ }
112
+ }
113
+ };
114
+
115
+ checkInitialUrl();
116
+
117
+ return () => {
118
+ mounted = false;
119
+ };
120
+ }, []);
121
+
122
+ // Listen for URL events (when app is already open)
123
+ useEffect(() => {
124
+ const handleUrl = (event: { url: string }) => {
125
+ const parsed = parseBundlerFromUrl(event.url);
126
+ if (parsed) {
127
+ setBundlerUrl(parsed);
128
+ }
129
+ };
130
+
131
+ const subscription = Linking.addEventListener("url", handleUrl);
132
+
133
+ return () => {
134
+ subscription.remove();
135
+ };
136
+ }, []);
137
+
138
+ return {
139
+ bundlerUrl,
140
+ isReady,
141
+ clear,
142
+ };
143
+ }
package/src/index.ts CHANGED
@@ -2,10 +2,11 @@
2
2
  * @teardown/dev-client
3
3
  *
4
4
  * React Native development client for Teardown applications.
5
- * Provides dev menu, splash screen, and error overlay functionality.
5
+ * Provides dev menu, splash screen, bundler waiting overlay, and error overlay functionality.
6
6
  */
7
7
 
8
8
  // Components
9
+ export { BundlerWaitingOverlay, type BundlerWaitingOverlayProps } from "./components/bundler-waiting";
9
10
  export { DevMenu, type DevMenuProps } from "./components/dev-menu";
10
11
  export { ErrorOverlay, type ErrorOverlayProps } from "./components/error-overlay";
11
12
  export { SplashScreen, type SplashScreenProps } from "./components/splash-screen";
@@ -23,6 +24,10 @@ export {
23
24
  type UseBundlerStatusOptions,
24
25
  useBundlerStatus,
25
26
  } from "./hooks/use-bundler-status";
27
+ export {
28
+ type UseDeepLinkBundlerReturn,
29
+ useDeepLinkBundler,
30
+ } from "./hooks/use-deep-link-bundler";
26
31
  export {
27
32
  createInitialDevMenuState,
28
33
  getDefaultMenuItems,