@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 +2 -2
- package/src/components/bundler-waiting/bundler-waiting-overlay.tsx +448 -0
- package/src/components/bundler-waiting/index.ts +7 -0
- package/src/components/index.ts +1 -0
- package/src/dev-client-provider.tsx +56 -4
- package/src/hooks/index.ts +5 -0
- package/src/hooks/use-deep-link-bundler.ts +143 -0
- package/src/index.ts +6 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@teardown/dev-client",
|
|
3
|
-
"version": "2.0.
|
|
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.
|
|
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
|
+
});
|
package/src/components/index.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
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:
|
|
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
|
}
|
package/src/hooks/index.ts
CHANGED
|
@@ -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,
|