@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.
@@ -0,0 +1,39 @@
1
+ /**
2
+ * @license React
3
+ * react-dom-client.production.js
4
+ *
5
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
6
+ *
7
+ * This source code is licensed under the MIT license found in the
8
+ * LICENSE file in the root directory of this source tree.
9
+ */
10
+
11
+ /**
12
+ * @license React
13
+ * react-dom.production.js
14
+ *
15
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
16
+ *
17
+ * This source code is licensed under the MIT license found in the
18
+ * LICENSE file in the root directory of this source tree.
19
+ */
20
+
21
+ /**
22
+ * @license React
23
+ * react.production.js
24
+ *
25
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
26
+ *
27
+ * This source code is licensed under the MIT license found in the
28
+ * LICENSE file in the root directory of this source tree.
29
+ */
30
+
31
+ /**
32
+ * @license React
33
+ * scheduler.production.js
34
+ *
35
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
36
+ *
37
+ * This source code is licensed under the MIT license found in the
38
+ * LICENSE file in the root directory of this source tree.
39
+ */
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "@axonpack/react-native-devtools-tab",
3
+ "version": "0.1.0",
4
+ "description": "Put your own tab in React Native DevTools, with a two-way channel to the running app",
5
+ "author": "Md Asadujjaman <abappi2019@gmail.com> (https://github.com/abappi19)",
6
+ "keywords": [
7
+ "react-native",
8
+ "devtools",
9
+ "metro",
10
+ "expo",
11
+ "debugging"
12
+ ],
13
+ "license": "MIT",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://github.com/axonpack/axonpack.git",
17
+ "directory": "packages/@axonpack/react-native-devtools-tab"
18
+ },
19
+ "publishConfig": {
20
+ "registry": "https://registry.npmjs.org/"
21
+ },
22
+ "files": [
23
+ "src/index.ts",
24
+ "src/core/constants",
25
+ "src/core/services/message-channel.service.ts",
26
+ "src/core/services/panel-channel.service.ts",
27
+ "src/core/services/tab-channel.service.ts",
28
+ "src/device",
29
+ "src/renderer/start-renderer.ts",
30
+ "src/renderer/services",
31
+ "dist",
32
+ "LICENSE",
33
+ "CHANGELOG.md",
34
+ "README.md"
35
+ ],
36
+ "exports": {
37
+ ".": "./src/index.ts",
38
+ "./renderer": "./src/renderer/start-renderer.ts",
39
+ "./metro": "./dist/metro/index.cjs",
40
+ "./package.json": "./package.json"
41
+ },
42
+ "scripts": {
43
+ "build": "rsbuild build",
44
+ "prepare": "bun run build",
45
+ "example:expo": "bun run build && cd example && bun run expo",
46
+ "clean": "rm -rf dist",
47
+ "lint": "oxlint src",
48
+ "check-types": "tsc --noEmit",
49
+ "test": "bun run build && bun test src"
50
+ },
51
+ "devDependencies": {
52
+ "@rsbuild/core": "^2.2.7",
53
+ "@types/bun": "^1.3.14",
54
+ "@types/jsdom": "^21.1.7",
55
+ "@types/react": "~19.1.17",
56
+ "@types/react-dom": "~19.2.2",
57
+ "jsdom": "^20.0.3",
58
+ "react": "19.2.3",
59
+ "react-dom": "19.2.3",
60
+ "react-native-web": "^0.21.2",
61
+ "react-reconciler": "0.33.0",
62
+ "typescript": "~5.9.3"
63
+ },
64
+ "engines": {
65
+ "node": ">=20"
66
+ },
67
+ "peerDependencies": {
68
+ "react": "^19.2.0"
69
+ },
70
+ "dependencies": {
71
+ "react-reconciler": "^0.33.0"
72
+ }
73
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The one name the app's side and the dev server's side have to agree on.
3
+ *
4
+ * It names the HTTP route the panel is served from, and the domain messages are tagged with on the
5
+ * debugger connection. React Native's own integration uses `react-devtools` on that same channel, so
6
+ * having a name at all is what keeps the two apart.
7
+ *
8
+ * A constant rather than an option, because nothing makes it vary: the dispatcher is per JS runtime
9
+ * and each dev server has its own port, so two apps never share either. Only two independent
10
+ * devtools instances *inside one app* would need to differ, and there is no such case.
11
+ */
12
+ export const DEVTOOLS_ID = "axonpack-rn-devtools";
13
+
14
+ export const DEVTOOLS_ROUTE = `/${DEVTOOLS_ID}-tab`;
15
+
16
+ /**
17
+ * Where the app keeps its tab list, for the frontend to read.
18
+ *
19
+ * A panel is built from what is on here, not from a message, so opening or reloading React Native
20
+ * DevTools finds the tabs that are already registered instead of having to be told about them again.
21
+ * The app has no way of knowing a frontend reloaded, and nothing re-announces, so a push alone left
22
+ * the tab strip empty until the app itself restarted.
23
+ */
24
+ export const DEVTOOLS_TABS = `__${DEVTOOLS_ID}_tabs__`;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Every message type on the wire, and what each carries.
3
+ *
4
+ * None of these name their tab, because the channel does. `createTabChannel` stamps the id on the
5
+ * way out and drops anything addressed elsewhere on the way in, so what actually crosses is the
6
+ * payload below plus that one field.
7
+ */
8
+
9
+ import type { RemoteOp } from "./remote-op.const";
10
+
11
+ /** App to panel, once per tab: the tab exists, and this is what to call it. */
12
+ export const REGISTER = "tab:register";
13
+
14
+ /** App to panel, on every render: what React just changed. */
15
+ export const MUTATE = "tab:mutate";
16
+
17
+ /** Panel to app: something in the tab was clicked, typed in, or otherwise acted on. */
18
+ export const ACTION = "tab:action";
19
+
20
+ /** Panel to app, on load: "describe yourself", since the app usually started first. */
21
+ export const HELLO = "tab:hello";
22
+
23
+ export type TabRegistration = { name: string; icon?: string };
24
+
25
+ export type TabMutation = { ops: RemoteOp[] };
26
+
27
+ /** `action` is the name a function prop was swapped for, not something anybody chose. */
28
+ export type TabAction = { action: string; payload?: unknown };
@@ -0,0 +1,37 @@
1
+ /**
2
+ * What a rendered component looks like on the wire.
3
+ *
4
+ * The app runs React and the panel owns the DOM, so what crosses is neither a component nor a
5
+ * picture of one: it is the list of changes React just made, replayed against real nodes at the far
6
+ * end. Nodes are named by number, and the panel keeps its own map from those numbers to elements.
7
+ *
8
+ * Kept as mutations rather than as a fresh tree each render, because replacing the tree would take
9
+ * the focus and the caret out of whatever input the user is typing in.
10
+ */
11
+
12
+ /** The container. Not an instance, so it has no id of its own. */
13
+ export const ROOT = 0;
14
+
15
+ export type RemoteProps = Record<string, unknown>;
16
+
17
+ /** A function prop, which cannot cross. The panel calls back with this name instead. */
18
+ export type RemoteHandler = { handler: string };
19
+
20
+ export function isHandler(value: unknown): value is RemoteHandler {
21
+ return (
22
+ typeof value === "object" &&
23
+ value !== null &&
24
+ typeof (value as RemoteHandler).handler === "string"
25
+ );
26
+ }
27
+
28
+ export type RemoteOp =
29
+ | { op: "create"; id: number; type: string; props: RemoteProps }
30
+ | { op: "text"; id: number; text: string }
31
+ | { op: "append"; parent: number; child: number }
32
+ | { op: "insert"; parent: number; child: number; before: number }
33
+ | { op: "remove"; parent: number; child: number }
34
+ | { op: "update"; id: number; props: RemoteProps }
35
+ | { op: "retext"; id: number; text: string }
36
+ /** Everything the panel holds is stale: it is being sent the tree from the start. */
37
+ | { op: "clear" };
@@ -0,0 +1,87 @@
1
+ /**
2
+ * A two-way message channel, with no opinion about what crosses it.
3
+ *
4
+ * Both ends of this library use it: the app's side and the panel's side are the same shape, because
5
+ * the panel is a separate JavaScript engine with no access to the app's memory. Everything it shows
6
+ * has to arrive as a message, and anything it wants the app to do has to go back the same way.
7
+ */
8
+
9
+ export type MessageListener = (payload: unknown) => void;
10
+
11
+ export type MessageChannel = {
12
+ /** Sends to the other end. Fire and forget. */
13
+ send: (type: string, payload?: unknown) => void;
14
+ /** Listens for what the other end sends. Call the returned function to stop. */
15
+ onMessage: (type: string, listener: MessageListener) => () => void;
16
+ };
17
+
18
+ /** The one envelope both ends agree on, so a transport only has to carry an object. */
19
+ export type Envelope = { type: string; data: unknown };
20
+
21
+ export function isEnvelope(value: unknown): value is Envelope {
22
+ return (
23
+ typeof value === "object" &&
24
+ value !== null &&
25
+ typeof (value as { type?: unknown }).type === "string"
26
+ );
27
+ }
28
+
29
+ export type Transport = {
30
+ post: (envelope: Envelope) => void;
31
+ /** Called with whatever arrived; hand it anything, the channel checks the shape. */
32
+ subscribe: (deliver: (value: unknown) => void) => void;
33
+ };
34
+
35
+ /**
36
+ * Builds a channel over a transport.
37
+ *
38
+ * Messages sent before the transport is ready are kept rather than dropped, in the shape they will
39
+ * go out in: a panel is opened long after an app starts, and the first thing a caller does is
40
+ * usually to send the state it already has.
41
+ */
42
+ export function createMessageChannel(
43
+ transport: Transport | null = null,
44
+ ): MessageChannel & {
45
+ attach: (transport: Transport) => void;
46
+ } {
47
+ const listeners = new Map<string, Set<MessageListener>>();
48
+ const backlog: Envelope[] = [];
49
+ let active: Transport | null = null;
50
+
51
+ const attach = (next: Transport): void => {
52
+ active = next;
53
+ next.subscribe((value) => {
54
+ if (!isEnvelope(value)) return;
55
+ for (const listener of listeners.get(value.type) ?? [])
56
+ listener(value.data);
57
+ });
58
+ for (const envelope of backlog.splice(0)) next.post(envelope);
59
+ };
60
+
61
+ if (transport) attach(transport);
62
+
63
+ const channel = {
64
+ attach,
65
+
66
+ send(type: string, payload?: unknown) {
67
+ const envelope: Envelope = { type, data: payload };
68
+ if (!active) {
69
+ backlog.push(envelope);
70
+ return;
71
+ }
72
+ active.post(envelope);
73
+ },
74
+
75
+ onMessage(type: string, listener: MessageListener) {
76
+ const existing = listeners.get(type) ?? new Set<MessageListener>();
77
+ existing.add(listener);
78
+ listeners.set(type, existing);
79
+
80
+ return () => {
81
+ listeners.get(type)?.delete(listener);
82
+ };
83
+ },
84
+ };
85
+
86
+ return channel;
87
+ }
@@ -0,0 +1,29 @@
1
+ import {
2
+ createMessageChannel,
3
+ type MessageChannel,
4
+ } from "./message-channel.service";
5
+
6
+ /**
7
+ * The browser end of the channel, for the page shown in a tab.
8
+ *
9
+ * The page is an iframe inside a DevTools panel, so messages to the app go up to the host script,
10
+ * which relays them over the debugger connection the frontend already has.
11
+ *
12
+ * In `core` rather than beside the renderer because it draws nothing. The renderer is built on it,
13
+ * and so is any page a consumer serves themselves.
14
+ */
15
+ export function createPanelChannel(): MessageChannel {
16
+ const channel = createMessageChannel({
17
+ post: (envelope) => window.parent.postMessage(envelope, "*"),
18
+ subscribe: (deliver) => {
19
+ window.addEventListener("message", (event: MessageEvent) =>
20
+ deliver(event.data),
21
+ );
22
+ },
23
+ });
24
+
25
+ return {
26
+ send: channel.send,
27
+ onMessage: channel.onMessage,
28
+ };
29
+ }
@@ -0,0 +1,33 @@
1
+ import type {
2
+ MessageChannel,
3
+ MessageListener,
4
+ } from "./message-channel.service";
5
+
6
+ /** A channel that only ever speaks for one tab, and only hears what is meant for it. */
7
+ export type TabChannel = {
8
+ send: (type: string, payload?: object) => void;
9
+ onMessage: (type: string, listener: MessageListener) => () => void;
10
+ };
11
+
12
+ /**
13
+ * Binds a channel to one tab.
14
+ *
15
+ * Both ends carry every tab over one connection, so each message has to name the one it belongs to.
16
+ * Doing that at the call sites meant stamping an id on the way out and comparing it on the way in,
17
+ * in eight places across two engines, where a missed check shows up as one tab drawing another
18
+ * tab's DOM. Here it is in one place and a caller cannot get it wrong.
19
+ */
20
+ export function createTabChannel(
21
+ channel: MessageChannel,
22
+ id: string,
23
+ ): TabChannel {
24
+ return {
25
+ send: (type, payload) => channel.send(type, { ...payload, id }),
26
+
27
+ onMessage: (type, listener) =>
28
+ channel.onMessage(type, (payload) => {
29
+ if ((payload as { id?: unknown } | undefined)?.id === id)
30
+ listener(payload);
31
+ }),
32
+ };
33
+ }
@@ -0,0 +1,139 @@
1
+ import {
2
+ createElement,
3
+ useEffect,
4
+ useReducer,
5
+ useState,
6
+ useTransition,
7
+ type ComponentType,
8
+ type ReactNode,
9
+ } from "react";
10
+
11
+ /**
12
+ * The bar above every tab, and the tab's own component under it.
13
+ *
14
+ * Here rather than in each consumer's component: the name is one `registerTab` was already handed,
15
+ * and a way to draw the tab again is the same button every time.
16
+ *
17
+ * Drawing again is a `useReducer` in the app rather than a message from the panel. The component
18
+ * runs here, so re-rendering it is a local matter and what the render changes reaches the panel as
19
+ * ordinary ops. It is also the one thing no hook covers: state a component reads that React is not
20
+ * watching.
21
+ *
22
+ * Styled by class, with the rules in `renderer/renderer.css`, so the bar follows the panel's light
23
+ * and dark instead of carrying colours picked in the app.
24
+ */
25
+ const DOCS = "https://axonpack.github.io/docs";
26
+ const HOME = "https://axonpack.github.io";
27
+ const REPO = "https://api.github.com/repos/axonpack/axonpack";
28
+
29
+ /**
30
+ * How long a refresh waits before the tab is drawn again.
31
+ *
32
+ * Deliberate. Re-rendering takes a few milliseconds and the ops reach the panel in a few more, so
33
+ * the loader was up for less than a frame and the button read as doing nothing at all. This is long
34
+ * enough to see that it did.
35
+ */
36
+ const RENDER_DELAY = 600;
37
+
38
+ /**
39
+ * The star count, or null until it arrives and for good if it never does.
40
+ *
41
+ * The request is kept at module level rather than per component: every tab's bar draws this card, so
42
+ * an app with four tabs would otherwise ask GitHub four times for the same number, against a limit
43
+ * of sixty an hour for an unauthenticated caller. It runs in the app, which is where a `fetch` is,
44
+ * and only once a panel has asked for the tab, so an app nobody is debugging never makes it.
45
+ */
46
+ let counted: Promise<number | null> | undefined;
47
+
48
+ function useStars(): number | null {
49
+ const [stars, setStars] = useState<number | null>(null);
50
+
51
+ useEffect(() => {
52
+ counted ??= fetch(REPO)
53
+ .then((response) => response.json())
54
+ .then((repo: { stargazers_count?: number }) =>
55
+ typeof repo.stargazers_count === "number"
56
+ ? repo.stargazers_count
57
+ : null,
58
+ )
59
+ .catch(() => null);
60
+
61
+ void counted.then(setStars);
62
+ }, []);
63
+
64
+ return stars;
65
+ }
66
+
67
+ export function TabFrame({
68
+ name,
69
+ component,
70
+ }: {
71
+ name: string;
72
+ component: ComponentType;
73
+ }): ReactNode {
74
+ const [, refresh] = useReducer((n: number) => n + 1, 0);
75
+ const [isLoading, startTransition] = useTransition();
76
+ const stars = useStars();
77
+
78
+ const handleRefresh = () => {
79
+ startTransition(async () => {
80
+ await new Promise((resolve) => setTimeout(resolve, RENDER_DELAY));
81
+ refresh();
82
+ });
83
+ };
84
+
85
+ return (
86
+ <>
87
+ <header className="axonpack-tab-bar">
88
+ <span className="axonpack-tab-name">
89
+ {/* The mark is drawn by the class, for the same reason the refresh glyph is. */}
90
+ <span className="axonpack-tab-brand" tabIndex={0}>
91
+ <span className="axonpack-tab-about">
92
+ <b>{name}</b>
93
+ <span>
94
+ This tab is rendered via Axonpack React Native DevTools Tab.
95
+ </span>
96
+ <a
97
+ className="axonpack-tab-card"
98
+ href={HOME}
99
+ target="_blank"
100
+ rel="noreferrer"
101
+ >
102
+ <span className="axonpack-tab-card-mark" />
103
+ <b>Axonpack</b>
104
+ <span className="axonpack-tab-card-stars">
105
+ {stars === null ? "" : `\u2605 ${stars}`}
106
+ </span>
107
+ <span className="axonpack-tab-card-slogan">
108
+ Free, open source foundation libraries for React Native and
109
+ Expo.
110
+ </span>
111
+ </a>
112
+ <span className="axonpack-tab-links">
113
+ <a href={DOCS} target="_blank" rel="noreferrer">
114
+ Learn more
115
+ </a>
116
+ </span>
117
+ </span>
118
+ </span>
119
+ {name}
120
+ </span>
121
+ {/* Empty: the glyph is a mask in `renderer/renderer.css`, since SVG cannot cross. */}
122
+ <button
123
+ onClick={handleRefresh}
124
+ title="Render this tab again"
125
+ aria-label="Render this tab again"
126
+ />
127
+ </header>
128
+ <div className="axonpack-tab-body">
129
+ {isLoading ? (
130
+ <div className="axonpack-tab-loading">
131
+ <span className="axonpack-tab-loading-glyph" />
132
+ </div>
133
+ ) : (
134
+ createElement(component)
135
+ )}
136
+ </div>
137
+ </>
138
+ );
139
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * `@types/react-reconciler` tracks the old versions rather than this one, and the whole host config
3
+ * is passed as one object anyway. So the two calls this package makes are declared here, next to the
4
+ * only file that makes them, the same way `metro/node.d.ts` declares the Node APIs it uses.
5
+ */
6
+
7
+ declare module "react-reconciler" {
8
+ import type { ReactNode } from "react";
9
+
10
+ export default function createReconciler(config: unknown): {
11
+ createContainer(
12
+ container: unknown,
13
+ tag: number,
14
+ hydrationCallbacks: unknown,
15
+ isStrictMode: boolean,
16
+ concurrentUpdatesByDefaultOverride: boolean | null,
17
+ identifierPrefix: string,
18
+ onUncaughtError: (error: unknown) => void,
19
+ onCaughtError: unknown,
20
+ onRecoverableError: unknown,
21
+ ): unknown;
22
+
23
+ updateContainer(
24
+ element: ReactNode,
25
+ container: unknown,
26
+ parentComponent: unknown,
27
+ callback: unknown,
28
+ ): void;
29
+ };
30
+ }
31
+
32
+ declare module "react-reconciler/constants" {
33
+ export const DefaultEventPriority: number;
34
+ }