Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
*.bundle.js
45 changes: 39 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,13 @@ npm install
npm run pod
npm run start
npm run macos
# for release builds
# for release builds (automatically bundles the server)
npm run macos-release
```

### Windows Development
**Note:** The Reactotron server is now embedded in the macOS app and starts automatically on port 9292. No need to run a separate server process!

### Windows Development

#### System Requirements

Expand Down Expand Up @@ -69,14 +71,26 @@ Both platforms use unified commands for native module development:

See [Making a TurboModule](./docs/Making-a-TurboModule.md) for detailed native development instructions.

### Server Bundle

If you modify the standalone server code (`standalone-server.js`), rebuild the bundle:

```sh
npm run bundle-server
```

The bundle is automatically generated during release builds (`npm run macos-release`).

## Enabling Reactotron in your app

> [!NOTE]
> We don't have a simple way to integrate the new Reactotron-macOS into your app yet, but that will be coming at some point. This assumes you've cloned down Reactotron-macOS.

1. From the root of Reactotron-macOS, start the standalone relay server:
`node -e "require('./standalone-server').startReactotronServer({ port: 9292 })"`
2. In your app, add the following to your app.tsx:
The Reactotron server is now embedded in the macOS app and starts automatically when you launch it. Simply:

1. Run the Reactotron macOS app (via `npm run macos` or the built .app)
2. The server will automatically start on port 9292
3. In your app, add the following to your app.tsx:

```tsx
if (__DEV__) {
Expand All @@ -92,7 +106,26 @@ if (__DEV__) {
}
```

3. Start your app and Reactotron-macOS. You should see logs appear.
4. Start your app and you should see logs appear in Reactotron.

### Running the Server Standalone (Optional)

If you need to run the server without the GUI (for CI/CD or headless environments), you can still run:

```sh
node -e "require('./standalone-server').startReactotronServer({ port: 9292 })"
```

### Server Implementation Details

The embedded server:

- Starts automatically when the app launches
- Stops automatically when the app quits
- Runs on port 9292 by default (configurable in `AppDelegate.mm`)
- Is bundled as a single file with all dependencies
- Requires Node.js to be installed on the system
- Supports nvm, asdf, fnm, and other Node version managers

## Get Help

Expand Down
48 changes: 36 additions & 12 deletions app/components/Sidebar/SidebarMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Animated, View, ViewStyle, Pressable, TextStyle, Text } from "react-native"
import { themed, useTheme, useThemeName } from "../../theme/theme"
import { useGlobal } from "../../state/useGlobal"
import { manualReconnect } from "../../state/connectToServer"
import { Icon } from "../Icon"

const MENU_ITEMS = [
Expand All @@ -24,6 +25,8 @@ export const SidebarMenu = ({ progress, mounted, collapsedWidth }: SidebarMenuPr
const theme = useTheme()
const [themeName, setTheme] = useThemeName()
const [isConnected] = useGlobal("isConnected", false)
const [connectionStatus] = useGlobal<string>("connectionStatus", "Disconnected")
const [clientIds] = useGlobal<string[]>("clientIds", [])
const [error] = useGlobal("error", null)
const arch = (global as any)?.nativeFabricUIManager ? "Fabric" : "Paper"

Expand Down Expand Up @@ -92,7 +95,12 @@ export const SidebarMenu = ({ progress, mounted, collapsedWidth }: SidebarMenuPr
})}
</View>
<View>
<View style={[$menuItem(), $statusItemContainer]}>
<Pressable
style={({ pressed }) => [$menuItem(), $statusItemContainer, pressed && $menuItemPressed]}
onPress={manualReconnect}
accessibilityRole="button"
accessibilityLabel={`Connection status: ${connectionStatus}. Tap to retry.`}
>
<View style={[{ width: iconColumnWidth }, $iconColumn()]}>
<View
style={[
Expand All @@ -104,17 +112,22 @@ export const SidebarMenu = ({ progress, mounted, collapsedWidth }: SidebarMenuPr
</View>

{mounted && (
<Animated.Text
style={[$menuItemText(), { opacity: labelOpacity }]}
numberOfLines={1}
ellipsizeMode="clip"
accessibilityElementsHidden={!mounted}
importantForAccessibility={mounted ? "auto" : "no-hide-descendants"}
>
Connection
</Animated.Text>
<Animated.View style={[$connectionContainer, { opacity: labelOpacity }]}>
<Animated.Text
style={[$menuItemText(), $connectionStatusText()]}
numberOfLines={2}
ellipsizeMode="tail"
accessibilityElementsHidden={!mounted}
importantForAccessibility={mounted ? "auto" : "no-hide-descendants"}
>
{connectionStatus}
</Animated.Text>
{isConnected && clientIds.length === 0 && (
<Text style={[$menuItemText(), $helpText()]}>Port 9292</Text>
)}
</Animated.View>
)}
</View>
</Pressable>
<View style={[$menuItem(), $statusItemContainer]}>
<View style={[{ width: iconColumnWidth }, $iconColumn()]}>
<View
Expand Down Expand Up @@ -218,4 +231,15 @@ const $statusText = themed<TextStyle>(({ colors }) => ({
fontWeight: "600",
marginLeft: -4,
}))
const $statusItemContainer: ViewStyle = { cursor: "default", height: 32 }
const $connectionStatusText = themed<TextStyle>(() => ({
fontSize: 11,
lineHeight: 14,
}))
const $helpText = themed<TextStyle>(({ colors }) => ({
fontSize: 10,
lineHeight: 12,
color: colors.neutral,
marginTop: 2,
}))
const $connectionContainer: ViewStyle = { flex: 1 }
const $statusItemContainer: ViewStyle = { cursor: "pointer", minHeight: 32 }
109 changes: 102 additions & 7 deletions app/state/connectToServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,17 @@ export const getReactotronAppId = () => {
return reactotronAppId
}

let reconnectAttempts = 0
let reconnectTimeout: NodeJS.Timeout | null = null
const MAX_RECONNECT_ATTEMPTS = 10
const INITIAL_RECONNECT_DELAY = 1000 // 1 second

/**
* Connects to the reactotron-core-server via websocket.
*
* Populates the following global state:
* - isConnected: boolean
* - connectionStatus: string (Connecting, Connected, Disconnected, Retrying...)
* - error: Error | null
* - clientIds: string[]
* - timelineItems: TimelineItem[]
Expand All @@ -30,6 +36,7 @@ export const getReactotronAppId = () => {
export function connectToServer(props: { port: number } = { port: 9292 }): UnsubscribeFn {
const reactotronAppId = getReactotronAppId()
const [_c, setIsConnected] = withGlobal("isConnected", false)
const [_cs, setConnectionStatus] = withGlobal<string>("connectionStatus", "Connecting...")
const [_e, setError] = withGlobal<Error | null>("error", null)
const [clientIds, setClientIds] = withGlobal<string[]>("clientIds", [])
const [, setActiveClientId] = withGlobal("activeClientId", "")
Expand All @@ -38,11 +45,36 @@ export function connectToServer(props: { port: number } = { port: 9292 }): Unsub
[clientId: string]: StateSubscription[]
}>("stateSubscriptionsByClientId", {})

ws.socket = new WebSocket(`ws://localhost:${props.port}`)
if (!ws.socket) throw new Error("Failed to connect to Reactotron server")
// Clear any existing reconnect timeout
if (reconnectTimeout) {
clearTimeout(reconnectTimeout)
reconnectTimeout = null
}

setConnectionStatus("Connecting...")
setError(null)

try {
ws.socket = new WebSocket(`ws://localhost:${props.port}`)
} catch (error) {
setError(error as Error)
setConnectionStatus("Failed to connect")
scheduleReconnect(props)
return () => {}
}

if (!ws.socket) {
setError(new Error("Failed to create WebSocket"))
setConnectionStatus("Failed to connect")
scheduleReconnect(props)
return () => {}
}

// Tell the server we are a Reactotron app, not a React client.
ws.socket.onopen = () => {
reconnectAttempts = 0 // Reset on successful connection
setConnectionStatus("Connected")
setError(null)
ws.socket?.send(
JSON.stringify({
type: "reactotron.subscribe",
Expand All @@ -54,16 +86,25 @@ export function connectToServer(props: { port: number } = { port: 9292 }): Unsub
}

// Handle errors
ws.socket.onerror = (event) => setError(new Error(`WebSocket error: ${event.message}`))
ws.socket.onerror = (event) => {
const errorMsg = event.message || "Connection failed"
setError(new Error(`WebSocket error: ${errorMsg}`))
setConnectionStatus(`Error: ${errorMsg}`)
}

// Handle messages coming from the server, intended to be sent to the client or Reactotron app.
ws.socket.onmessage = (event) => {
const data = JSON.parse(event.data)
console.tron.log("Received message from server:", data.type)

if (data.type === "reactotron.connected") setIsConnected(true)
if (data.type === "reactotron.connected") {
console.tron.log("Reactotron app marked as connected")
setIsConnected(true)
}

if (data.type === "connectionEstablished") {
const clientId = data?.conn?.clientId
console.tron.log("connectionEstablished event for client:", clientId)
if (!clientIds.includes(clientId)) {
setClientIds((prev) => [...prev, clientId])
setActiveClientId(clientId)
Expand All @@ -75,6 +116,12 @@ export function connectToServer(props: { port: number } = { port: 9292 }): Unsub
}

if (data.type === "connectedClients") {
console.tron.log(
"connectedClients event. Count:",
data.clients?.length,
"Clients:",
data.clients,
)
let newestClientId = ""
data.clients.forEach((client: any) => {
// Store the client data in global state
Expand All @@ -89,6 +136,7 @@ export function connectToServer(props: { port: number } = { port: 9292 }): Unsub

if (newestClientId) {
// Set the active client to the newest client
console.tron.log("Setting active client to:", newestClientId)
setActiveClientId(newestClientId)
}
}
Expand Down Expand Up @@ -159,8 +207,9 @@ export function connectToServer(props: { port: number } = { port: 9292 }): Unsub
}

// Clean up after disconnect
ws.socket.onclose = () => {
console.tron.log("Reactotron server disconnected")
ws.socket.onclose = (event) => {
console.tron.log("Reactotron server disconnected", event.code, event.reason)

// Clear individual client data
clientIds.forEach((clientId) => {
const [_, setClientData] = withGlobal(`client-${clientId}`, {})
Expand All @@ -172,6 +221,14 @@ export function connectToServer(props: { port: number } = { port: 9292 }): Unsub
setActiveClientId("")
setTimelineItems([])
setStateSubscriptionsByClientId({})

// Only attempt reconnect if it wasn't a normal close
if (event.code !== 1000) {
setConnectionStatus("Disconnected")
scheduleReconnect(props)
} else {
setConnectionStatus("Disconnected")
}
}

// Send a message to the server (which will be forwarded to the client)
Expand All @@ -181,9 +238,47 @@ export function connectToServer(props: { port: number } = { port: 9292 }): Unsub
}

return () => {
ws.socket?.close()
if (reconnectTimeout) {
clearTimeout(reconnectTimeout)
reconnectTimeout = null
}
reconnectAttempts = 0
ws.socket?.close(1000) // Normal closure
ws.socket = null
}
}

function scheduleReconnect(props: { port: number }) {
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
const [_, setConnectionStatus] = withGlobal<string>("connectionStatus", "")
setConnectionStatus(`Failed after ${MAX_RECONNECT_ATTEMPTS} attempts`)
return
}

reconnectAttempts++
const delay = Math.min(INITIAL_RECONNECT_DELAY * Math.pow(2, reconnectAttempts - 1), 30000)
const [_, setConnectionStatus] = withGlobal<string>("connectionStatus", "")
setConnectionStatus(
`Retrying in ${Math.round(delay / 1000)}s... (${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`,
)

reconnectTimeout = setTimeout(() => {
console.tron.log(`Reconnecting... attempt ${reconnectAttempts}`)
connectToServer(props)
}, delay)
}

export function manualReconnect() {
reconnectAttempts = 0
if (reconnectTimeout) {
clearTimeout(reconnectTimeout)
reconnectTimeout = null
}
if (ws.socket) {
ws.socket.close(1000)
ws.socket = null
}
connectToServer()
}

export function sendToClient(message: string | object, payload?: object, clientId?: string) {
Expand Down
2 changes: 1 addition & 1 deletion app/state/useGlobal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ function buildSetValue<T>(id: string, persist: boolean) {
}

export function deleteGlobal(id: string): void {
delete globals[id]
delete _globals[id]
}

/**
Expand Down
Loading