@axonpack/react-native-devtools-tab 0.1.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/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +359 -0
- package/dist/metro/index.cjs +435 -0
- package/dist/renderer/index.html +19 -0
- package/dist/renderer/static/css/index.c8f13abbf0.css +1 -0
- package/dist/renderer/static/js/index.8039fdb712.js +2 -0
- package/dist/renderer/static/js/index.8039fdb712.js.LICENSE.txt +39 -0
- package/package.json +73 -0
- package/src/core/constants/devtools.const.ts +24 -0
- package/src/core/constants/message.const.ts +28 -0
- package/src/core/constants/remote-op.const.ts +37 -0
- package/src/core/services/message-channel.service.ts +87 -0
- package/src/core/services/panel-channel.service.ts +29 -0
- package/src/core/services/tab-channel.service.ts +33 -0
- package/src/device/components/tab-frame.component.tsx +139 -0
- package/src/device/react-reconciler.d.ts +34 -0
- package/src/device/services/__tests__/remote-sender.test.ts +461 -0
- package/src/device/services/fusebox-transport.service.ts +106 -0
- package/src/device/services/remote-sender.service.ts +413 -0
- package/src/index.ts +175 -0
- package/src/renderer/services/__tests__/remote-receiver.test.ts +126 -0
- package/src/renderer/services/remote-receiver.service.ts +150 -0
- package/src/renderer/start-renderer.ts +80 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# @axonpack/react-native-devtools-tab
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- e1a59b0: - **Your own tab in React Native DevTools**, beside Console and Sources, from one call to `registerTab`
|
|
8
|
+
- **A tab is a React component** you write, with hooks, effects and anything it composes
|
|
9
|
+
- **It runs inside the app**, so it reads the app's own state and a button in it calls the app's own functions
|
|
10
|
+
- **No copy to keep in sync**: subscribe to a store and the tab redraws the moment that store changes
|
|
11
|
+
- **Write the tab in React Native or in HTML**, whichever suits it, both drawn by the panel
|
|
12
|
+
- **Name and symbol per tab**, and as many tabs as you register
|
|
13
|
+
- **A bar above every tab** with its name and a button that draws it again
|
|
14
|
+
- **One line in the Metro config** is the whole setup, with no fork of the DevTools frontend, no browser extension and no second window
|
|
15
|
+
- **Nothing ships to production**: guard the registrations with `__DEV__` and a release bundle carries none of it
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Md Asadujjaman
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
# @axonpack/react-native-devtools-tab
|
|
2
|
+
|
|
3
|
+
Put a tab of your own in React Native DevTools, and talk to the running app through it.
|
|
4
|
+
|
|
5
|
+
Your tab is part of the app, so it can use whatever the app can. Import your store and read it. When
|
|
6
|
+
that state changes, the tab updates with it, because it is the same state and not a copy of it. A
|
|
7
|
+
button in the tab calls your real function.
|
|
8
|
+
|
|
9
|
+
No fork of the DevTools frontend, no browser extension, no second window, and no bundler: you
|
|
10
|
+
describe what the tab shows and this renders it.
|
|
11
|
+
|
|
12
|
+
[Watch the demo](https://youtu.be/qMN6QO-qlF4)
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
bun add @axonpack/react-native-devtools-tab
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
React 19.2 or newer is the one declared peer, and the Metro side wants Node 20 or newer. The tab
|
|
21
|
+
lives in React Native DevTools, so the app has to be one whose debugger is that rather than the old
|
|
22
|
+
Chrome remote debugger.
|
|
23
|
+
|
|
24
|
+
## Set it up
|
|
25
|
+
|
|
26
|
+
Two files of your own, plus one line in the Metro config.
|
|
27
|
+
|
|
28
|
+
### 1. Wrap the Metro config
|
|
29
|
+
|
|
30
|
+
This is what serves the tab to the DevTools page.
|
|
31
|
+
|
|
32
|
+
```js
|
|
33
|
+
// metro.config.js
|
|
34
|
+
const { getDefaultConfig } = require("expo/metro-config");
|
|
35
|
+
const {
|
|
36
|
+
withReactNativeDevtoolsTab,
|
|
37
|
+
} = require("@axonpack/react-native-devtools-tab/metro");
|
|
38
|
+
|
|
39
|
+
module.exports = withReactNativeDevtoolsTab(getDefaultConfig(__dirname));
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
A bare React Native app is the same line with its own default config, from
|
|
43
|
+
`@react-native/metro-config`.
|
|
44
|
+
|
|
45
|
+
### 2. Write a tab and register it
|
|
46
|
+
|
|
47
|
+
A tab is any React component, and `registerTab` takes it. One file does both:
|
|
48
|
+
|
|
49
|
+
```tsx
|
|
50
|
+
// devtools.tsx
|
|
51
|
+
import { useState } from "react";
|
|
52
|
+
import { ReactNativeDevtoolsPanel } from "@axonpack/react-native-devtools-tab";
|
|
53
|
+
|
|
54
|
+
function Session() {
|
|
55
|
+
const [count, setCount] = useState(0);
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div style={{ padding: 12 }}>
|
|
59
|
+
<p>pressed {count} times</p>
|
|
60
|
+
<button onClick={() => setCount(count + 1)}>press</button>
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
ReactNativeDevtoolsPanel.registerTab({
|
|
66
|
+
name: "Session",
|
|
67
|
+
icon: "\u269b",
|
|
68
|
+
component: Session,
|
|
69
|
+
});
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`name` is the label in the tab strip and what the tab's id is built from. `icon` is optional text
|
|
73
|
+
shown after it. `component` is what the tab draws. Call `registerTab` again in the same file for a
|
|
74
|
+
second tab, and split the components out into their own files whenever the file stops being
|
|
75
|
+
comfortable.
|
|
76
|
+
|
|
77
|
+
The JSX can be `div` and `button` because the elements are made in a browser, at the panel's end. It
|
|
78
|
+
can equally be React Native, which the panel draws with react-native-web:
|
|
79
|
+
|
|
80
|
+
```tsx
|
|
81
|
+
import { Pressable, Text, View } from "react-native";
|
|
82
|
+
|
|
83
|
+
function Session() {
|
|
84
|
+
const [count, setCount] = useState(0);
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<View style={{ padding: 12, gap: 8 }}>
|
|
88
|
+
<Text>pressed {count} times</Text>
|
|
89
|
+
<Pressable onPress={() => setCount(count + 1)}>
|
|
90
|
+
<Text>press</Text>
|
|
91
|
+
</Pressable>
|
|
92
|
+
</View>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Layout, text and presses come through. What does not is behaviour that lives in native code rather
|
|
98
|
+
than in the JavaScript, so `SafeAreaView` lays out with no insets and a natively driven `Animated`
|
|
99
|
+
sits still. [It runs in the app](#it-runs-in-the-app) is where that line sits.
|
|
100
|
+
|
|
101
|
+
### 3. Pull that file in when the app starts
|
|
102
|
+
|
|
103
|
+
`devtools.tsx` is pulled in for its side effect, so it needs no export. Put the line in
|
|
104
|
+
`app/_layout.tsx` for Expo Router, or `App.tsx` otherwise:
|
|
105
|
+
|
|
106
|
+
```tsx
|
|
107
|
+
// app/_layout.tsx
|
|
108
|
+
if (__DEV__) require("../devtools");
|
|
109
|
+
|
|
110
|
+
export default function Layout() {
|
|
111
|
+
// your app, unchanged
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Calling `registerTab` later works too, and so does a plain `import "../devtools"`. The `__DEV__`
|
|
116
|
+
guard is what keeps the tabs out of a release bundle. [In a release build](#in-a-release-build) has
|
|
117
|
+
the detail.
|
|
118
|
+
|
|
119
|
+
Restart Metro, open React Native DevTools, and the tab is in the strip beside Console and Sources.
|
|
120
|
+
If it is not, [when the tab does not appear](#when-the-tab-does-not-appear) lists what each failure
|
|
121
|
+
prints.
|
|
122
|
+
|
|
123
|
+
## It runs in the app
|
|
124
|
+
|
|
125
|
+
React renders your component **here, in the app**, against a renderer that reports what it drew
|
|
126
|
+
instead of touching a DOM. The panel builds the real elements from the changes that arrive.
|
|
127
|
+
|
|
128
|
+
That is what lets `onClick` call `auth.signOut()` directly: the handler and the app's own state are
|
|
129
|
+
in the same place, so there is no message to write and no state to mirror. Hooks, effects and any
|
|
130
|
+
component you compose all work, because this is React.
|
|
131
|
+
|
|
132
|
+
It is also why the JSX can be `div` and `button`. The elements are made at the other end, in a
|
|
133
|
+
browser.
|
|
134
|
+
|
|
135
|
+
It can equally be `View`, `Text`, `Image`, `ScrollView` and `Pressable`. Those reach the panel as the
|
|
136
|
+
host elements React Native compiled them to (`RCTView`, `RCTText`), and the panel draws them with
|
|
137
|
+
**react-native-web**, the library that renders a React Native app in a browser. Yoga's defaults, the
|
|
138
|
+
units, the text layout and the stylesheet are its job, in the page where it has a real document.
|
|
139
|
+
|
|
140
|
+
What crosses is host output, so a component whose behaviour is native code does not get that
|
|
141
|
+
behaviour: `SafeAreaView` lays out but has no insets, an SVG path is an empty box, and `Animated`
|
|
142
|
+
driven natively sits at its first value. Layout, text and presses come through.
|
|
143
|
+
|
|
144
|
+
This is the trade the package makes on purpose. A tab could instead be built for the web by your own
|
|
145
|
+
Metro, which renders every React Native component perfectly, and it would then be a separate
|
|
146
|
+
JavaScript world that cannot see one value of the app's. Reading the running app is the point, so
|
|
147
|
+
the app is where a tab runs.
|
|
148
|
+
|
|
149
|
+
Changes cross as changes, not as a new tree, so the panel keeps the element it already had. An input
|
|
150
|
+
holds its caret and a scrolled list stays where it was while something above it re-renders.
|
|
151
|
+
|
|
152
|
+
## Talking to the app
|
|
153
|
+
|
|
154
|
+
A tab's component runs in the app, so most of this is not communication at all.
|
|
155
|
+
|
|
156
|
+
- **Tab to app:** call it. `onClick={() => auth.signOut()}` is a function call, not a message.
|
|
157
|
+
- **App to tab:** whatever the app already uses to hold state. A tab is real React, so `useState`,
|
|
158
|
+
an emitter, Zustand, Jotai and MobX all work as they do anywhere else.
|
|
159
|
+
|
|
160
|
+
A store is a store. This one is plain React, with no library:
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
// store.ts
|
|
164
|
+
import { useSyncExternalStore } from "react";
|
|
165
|
+
|
|
166
|
+
let value = { user: "nobody", requests: 0 };
|
|
167
|
+
const listeners = new Set<() => void>();
|
|
168
|
+
|
|
169
|
+
export const session = {
|
|
170
|
+
get: () => value,
|
|
171
|
+
|
|
172
|
+
set(next: typeof value) {
|
|
173
|
+
value = next;
|
|
174
|
+
for (const listener of listeners) listener();
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
use: () =>
|
|
178
|
+
useSyncExternalStore(
|
|
179
|
+
(listener) => {
|
|
180
|
+
listeners.add(listener);
|
|
181
|
+
return () => listeners.delete(listener);
|
|
182
|
+
},
|
|
183
|
+
() => value,
|
|
184
|
+
),
|
|
185
|
+
};
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
The tab imports it like any other file, because it is running in the app where that file already
|
|
189
|
+
lives:
|
|
190
|
+
|
|
191
|
+
```tsx
|
|
192
|
+
// devtools.tsx
|
|
193
|
+
import { session } from "./store";
|
|
194
|
+
|
|
195
|
+
export default function Session() {
|
|
196
|
+
const { user, requests } = session.use();
|
|
197
|
+
|
|
198
|
+
return (
|
|
199
|
+
<div style={{ padding: 12 }}>
|
|
200
|
+
<p>
|
|
201
|
+
{user}, {requests} requests
|
|
202
|
+
</p>
|
|
203
|
+
<button onClick={() => session.set({ user: "nobody", requests })}>
|
|
204
|
+
sign out
|
|
205
|
+
</button>
|
|
206
|
+
</div>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
`session.use()` is the same hook your screens call, on the same object. Press a button on a screen
|
|
212
|
+
and the tab follows, because there is one value and not a copy of one.
|
|
213
|
+
|
|
214
|
+
What does not reach a tab is **React context**: a tab is its own root, so the app's providers are in
|
|
215
|
+
a different tree.
|
|
216
|
+
|
|
217
|
+
## The bar at the top
|
|
218
|
+
|
|
219
|
+
Every tab gets one, drawn by this package: its `name`, and a button that renders the component
|
|
220
|
+
again. Nothing to add and nothing to wire up, and your component starts under it.
|
|
221
|
+
|
|
222
|
+
That button is for state a component reads but React is not watching. Everything else redraws on its
|
|
223
|
+
own, because a tab is ordinary React.
|
|
224
|
+
|
|
225
|
+
## Text inputs
|
|
226
|
+
|
|
227
|
+
Use `defaultValue`, not `value`.
|
|
228
|
+
|
|
229
|
+
A controlled `TextInput` keeps its native view in step by sending it a command, and a tab has no
|
|
230
|
+
native view: the ref it gets is a stand-in, so React Native warns that `dispatchCommand` was given a
|
|
231
|
+
ref that is not a native component, and typing throws. Uncontrolled, it never asks, and
|
|
232
|
+
`onChangeText` still arrives on every keystroke.
|
|
233
|
+
|
|
234
|
+
```tsx
|
|
235
|
+
<TextInput defaultValue="" onChangeText={setQuery} />
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Clear it by remounting, which is what a `key` that changes does. A `div`-and-`input` tab has none of
|
|
239
|
+
this, because React and the browser own both ends of it.
|
|
240
|
+
|
|
241
|
+
## What it cannot do
|
|
242
|
+
|
|
243
|
+
Touch a real element. A `ref` gets a stand-in, so a canvas, a measurement or a DOM library has
|
|
244
|
+
nothing to work with, and an event arrives as `{ type, target: { value, checked } }` rather than the
|
|
245
|
+
event itself, with `preventDefault` and `stopPropagation` there but doing nothing.
|
|
246
|
+
|
|
247
|
+
Everything else a devtools tab usually wants needs none of that: reading the app's state, calling
|
|
248
|
+
into it, drawing a table, filtering a list.
|
|
249
|
+
|
|
250
|
+
## Several tabs
|
|
251
|
+
|
|
252
|
+
Call `registerTab` once per tab. Each gets its own React root, so one tab re-rendering does not
|
|
253
|
+
touch another, and a tab nobody has opened still runs: it is the app rendering, not the panel.
|
|
254
|
+
|
|
255
|
+
Tabs identify themselves. An id is built from `name` at startup, numbered if two tabs share a name,
|
|
256
|
+
and every message is stamped and filtered with it, so two tabs never see each other's traffic and
|
|
257
|
+
nothing you write can get that wrong.
|
|
258
|
+
|
|
259
|
+
## The tab's symbol
|
|
260
|
+
|
|
261
|
+
`icon` is text shown after the tab's name, defaulting to this package's mark. Any character works,
|
|
262
|
+
so an emoji does too. It is text rather than an image because React Native DevTools' own icon slots
|
|
263
|
+
take an element, and every way of putting one there loses its drawing.
|
|
264
|
+
|
|
265
|
+
## In a release build
|
|
266
|
+
|
|
267
|
+
`registerTab` is safe to call unguarded. A release build installs no debugger dispatcher, so nothing
|
|
268
|
+
connects, your component is never rendered, and its effects never run. All the call costs is an entry
|
|
269
|
+
in an array.
|
|
270
|
+
|
|
271
|
+
To keep the tabs out of the release bundle as well, put the calls in a module of their own and pull
|
|
272
|
+
it in behind `__DEV__`:
|
|
273
|
+
|
|
274
|
+
```tsx
|
|
275
|
+
if (__DEV__) require("../devtools");
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Metro replaces `__DEV__` with `false` and folds the dead branch away _before_ it collects
|
|
279
|
+
dependencies, so that module and everything it imports is left out of the bundle entirely. It has to
|
|
280
|
+
be a `require` rather than an `import`, because an `import` is hoisted and runs whatever the branch
|
|
281
|
+
around it says.
|
|
282
|
+
|
|
283
|
+
The Metro wrap costs a release bundle nothing either way. The only thing it adds to the config is
|
|
284
|
+
`server.enhanceMiddleware`, which nothing but a dev server reads. It touches no transformer, no
|
|
285
|
+
resolver and no serializer, so the bundle is the same with it as without.
|
|
286
|
+
|
|
287
|
+
The config is also the one place that `__DEV__` guard does not work. The global belongs to the app's
|
|
288
|
+
bundle, while `metro.config.js` runs in Node, where it does not exist, so `if (!__DEV__) return
|
|
289
|
+
config` throws before Metro starts. Metro reads the config when building for release too, so the
|
|
290
|
+
package has to be installed then: keep it out of `devDependencies` if your release install skips
|
|
291
|
+
those.
|
|
292
|
+
|
|
293
|
+
## When the tab does not appear
|
|
294
|
+
|
|
295
|
+
Every failure says so, either in Metro's output or in the DevTools console.
|
|
296
|
+
|
|
297
|
+
**`[devtools] React Native DevTools was not found, so no tab is served.`** The helper could not
|
|
298
|
+
resolve `@react-native/debugger-frontend` from your project. A monorepo that hoists oddly is the
|
|
299
|
+
usual cause, and `frontendPath` is the way out:
|
|
300
|
+
|
|
301
|
+
Ask your project for the path. The package's entry point exports it as a string, so this prints it:
|
|
302
|
+
|
|
303
|
+
```sh
|
|
304
|
+
node -p "require('@react-native/debugger-frontend')"
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
Run that from your app's directory, and hand the result over:
|
|
308
|
+
|
|
309
|
+
```js
|
|
310
|
+
module.exports = withReactNativeDevtoolsTab(getDefaultConfig(__dirname), {
|
|
311
|
+
frontendPath:
|
|
312
|
+
"/your/app/node_modules/@react-native/debugger-frontend/dist/third-party/front_end",
|
|
313
|
+
});
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
Note how deep it sits. It is the directory the files are actually in, several levels below the
|
|
317
|
+
package root, not the package root itself. `ls` it and you should see `rn_fusebox.html`, which is
|
|
318
|
+
the page DevTools opens. If the `node -p` fails too, the package is genuinely not installed where
|
|
319
|
+
your app can see it, and the fix is the install rather than this option.
|
|
320
|
+
|
|
321
|
+
**`[devtools] could not point the debugger shortcut at the tab.`** The frontend was found but
|
|
322
|
+
`@react-native/dev-middleware` was not, so DevTools opens on its own route with no tab script in it.
|
|
323
|
+
Two copies of that package in one install is the usual reason.
|
|
324
|
+
|
|
325
|
+
**`[devtools] the app never installed its devtools dispatcher`** Printed in the DevTools console
|
|
326
|
+
after ten seconds of asking. The panel is up and the app is not answering, so either the app's bundle
|
|
327
|
+
never pulled this package in, or it did so behind a `__DEV__` guard in a build where that is false.
|
|
328
|
+
|
|
329
|
+
Nothing printed at all, and no tab: Metro was not restarted after the config changed.
|
|
330
|
+
|
|
331
|
+
## How it works
|
|
332
|
+
|
|
333
|
+
React Native DevTools is a Chrome DevTools frontend fork, served by `@react-native/dev-middleware`.
|
|
334
|
+
This serves that same frontend from its own route, adds a nonce to the page's CSP and injects one
|
|
335
|
+
script, and that script re-imports the frontend's own modules to reach `InspectorView.addPanel`.
|
|
336
|
+
The tab's body is an iframe, and it reaches the device over the debugger connection the frontend
|
|
337
|
+
already has, tagged with the tab's own id so it never crosses React DevTools' own traffic.
|
|
338
|
+
|
|
339
|
+
Inside that iframe is a custom React reconciler's other half. The app's React commits produce a list
|
|
340
|
+
of changes (create this node, move that one, set these props) and the page replays them onto real
|
|
341
|
+
elements. A function prop cannot be sent, so each is swapped for the position it sits at in the tree;
|
|
342
|
+
the page calls back with that position and the app runs the closure it stands for.
|
|
343
|
+
|
|
344
|
+
Neither `@react-native/debugger-frontend` nor `@react-native/dev-middleware` is declared here. Both
|
|
345
|
+
already arrive with `react-native` and are versioned in lockstep with it, so the Metro helper
|
|
346
|
+
resolves them at run time starting from whatever is serving your project. The copy it uses is
|
|
347
|
+
therefore the copy your dev server uses. A pin of our own could land a version that speaks slightly
|
|
348
|
+
different CDP to your app.
|
|
349
|
+
|
|
350
|
+
## Playground
|
|
351
|
+
|
|
352
|
+
`example/` is an Expo app for working on this package. It registers five tabs: a counter sharing a
|
|
353
|
+
value with the app's own screen, a shell running a command on the machine Metro is on, a check that a
|
|
354
|
+
string which looks like markup renders as that string, a reader for the app's MMKV store, and one
|
|
355
|
+
querying its SQLite database.
|
|
356
|
+
|
|
357
|
+
```sh
|
|
358
|
+
cd example && bun run start
|
|
359
|
+
```
|