@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
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import type { RemoteOp } from "../../../core/constants/remote-op.const";
|
|
4
|
+
import {
|
|
5
|
+
createRemoteReceiver,
|
|
6
|
+
type RemoteNode,
|
|
7
|
+
} from "../remote-receiver.service";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Ops by hand, so these say what an op means rather than what React happens to emit.
|
|
11
|
+
*
|
|
12
|
+
* The tree only, with no document anywhere: this side stopped building elements when
|
|
13
|
+
* react-native-web took that job over, and what it holds now is what the panel's React renders
|
|
14
|
+
* from. `remote-tree.component` is where that rendering is tested.
|
|
15
|
+
*/
|
|
16
|
+
function receive() {
|
|
17
|
+
const receiver = createRemoteReceiver();
|
|
18
|
+
|
|
19
|
+
return { receiver, apply: (...ops: RemoteOp[]) => receiver.apply(ops) };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** What is on screen, as a plain object a failed expectation can print in full. */
|
|
23
|
+
|
|
24
|
+
function shapeOf(node: RemoteNode): unknown {
|
|
25
|
+
if (node.type === "#text") return node.text;
|
|
26
|
+
return {
|
|
27
|
+
type: node.type,
|
|
28
|
+
props: node.props,
|
|
29
|
+
children: node.children.map(shapeOf),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test("a prop that stops being sent is dropped rather than carried as null", () => {
|
|
34
|
+
const { receiver, apply } = receive();
|
|
35
|
+
|
|
36
|
+
apply(
|
|
37
|
+
{ op: "create", id: 1, type: "div", props: { title: "wait", id: "busy" } },
|
|
38
|
+
{ op: "append", parent: 0, child: 1 },
|
|
39
|
+
);
|
|
40
|
+
expect(receiver.root.children[0].props).toEqual({
|
|
41
|
+
title: "wait",
|
|
42
|
+
id: "busy",
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// What the sender emits once React drops both props. Carrying the null on would hand React a
|
|
46
|
+
// `title={null}`, which is a prop that is still there.
|
|
47
|
+
apply({ op: "update", id: 1, props: { title: null, id: null } });
|
|
48
|
+
|
|
49
|
+
expect(receiver.root.children[0].props).toEqual({});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("an update keeps the props it says nothing about", () => {
|
|
53
|
+
const { receiver, apply } = receive();
|
|
54
|
+
|
|
55
|
+
apply(
|
|
56
|
+
{ op: "create", id: 1, type: "input", props: { value: "ada", id: "who" } },
|
|
57
|
+
{ op: "append", parent: 0, child: 1 },
|
|
58
|
+
{ op: "update", id: 1, props: { value: "grace" } },
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
expect(receiver.root.children[0].props).toEqual({
|
|
62
|
+
value: "grace",
|
|
63
|
+
id: "who",
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("clear empties the screen but keeps the ids", () => {
|
|
68
|
+
const { receiver, apply } = receive();
|
|
69
|
+
|
|
70
|
+
// The order React itself uses on a first commit: it creates the nodes, then clears the container,
|
|
71
|
+
// then appends. Dropping the ids on clear would throw away the tree being built.
|
|
72
|
+
apply(
|
|
73
|
+
{ op: "create", id: 1, type: "p", props: {} },
|
|
74
|
+
{ op: "text", id: 2, text: "still here" },
|
|
75
|
+
{ op: "clear" },
|
|
76
|
+
{ op: "append", parent: 0, child: 1 },
|
|
77
|
+
{ op: "append", parent: 1, child: 2 },
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
expect(shapeOf(receiver.root)).toEqual({
|
|
81
|
+
type: "#root",
|
|
82
|
+
props: {},
|
|
83
|
+
children: [{ type: "p", props: {}, children: ["still here"] }],
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("a node is put where it was asked for, and taken out with its subtree", () => {
|
|
88
|
+
const { receiver, apply } = receive();
|
|
89
|
+
|
|
90
|
+
apply(
|
|
91
|
+
{ op: "create", id: 1, type: "p", props: {} },
|
|
92
|
+
{ op: "create", id: 2, type: "p", props: {} },
|
|
93
|
+
{ op: "create", id: 3, type: "span", props: {} },
|
|
94
|
+
{ op: "append", parent: 0, child: 1 },
|
|
95
|
+
{ op: "append", parent: 0, child: 2 },
|
|
96
|
+
{ op: "append", parent: 2, child: 3 },
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
apply({ op: "insert", parent: 0, child: 2, before: 1 });
|
|
100
|
+
expect(receiver.root.children.map((node) => node.id)).toEqual([2, 1]);
|
|
101
|
+
|
|
102
|
+
apply({ op: "remove", parent: 0, child: 2 });
|
|
103
|
+
expect(receiver.root.children.map((node) => node.id)).toEqual([1]);
|
|
104
|
+
|
|
105
|
+
// The subtree's ids went with it, so a stale op cannot reach a node that is off screen.
|
|
106
|
+
apply({ op: "append", parent: 0, child: 3 });
|
|
107
|
+
expect(receiver.root.children.map((node) => node.id)).toEqual([1]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("every applied batch is one version, which is what the panel re-renders on", () => {
|
|
111
|
+
const { receiver, apply } = receive();
|
|
112
|
+
|
|
113
|
+
let told = 0;
|
|
114
|
+
const stop = receiver.subscribe(() => told++);
|
|
115
|
+
|
|
116
|
+
const before = receiver.version();
|
|
117
|
+
apply({ op: "text", id: 1, text: "a" });
|
|
118
|
+
apply({ op: "retext", id: 1, text: "b" });
|
|
119
|
+
|
|
120
|
+
expect(receiver.version()).toBe(before + 2);
|
|
121
|
+
expect(told).toBe(2);
|
|
122
|
+
|
|
123
|
+
stop();
|
|
124
|
+
apply({ op: "retext", id: 1, text: "c" });
|
|
125
|
+
expect(told).toBe(2);
|
|
126
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ROOT,
|
|
3
|
+
type RemoteOp,
|
|
4
|
+
type RemoteProps,
|
|
5
|
+
} from "../../core/constants/remote-op.const";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The tree the app's React drew, kept as data for the panel to render.
|
|
9
|
+
*
|
|
10
|
+
* This used to build DOM nodes itself, one `document.createElement` per op. It cannot any more: a
|
|
11
|
+
* tab written with `View` and `Text` arrives as the host elements React Native compiles those to,
|
|
12
|
+
* and turning `RCTView` into a `div` by hand means reimplementing Yoga's defaults, RN's units and
|
|
13
|
+
* its text layout. react-native-web already is that, exactly, and it is a browser library, so it
|
|
14
|
+
* runs here. Handing it a tree means this side holds nodes rather than elements and React does the
|
|
15
|
+
* building.
|
|
16
|
+
*
|
|
17
|
+
* Kept as mutations applied to one tree, as before. React then diffs, so the element under a caret
|
|
18
|
+
* or a scrolled list survives a render above it for the same reason it did when this patched the
|
|
19
|
+
* DOM directly.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export type RemoteNode = {
|
|
23
|
+
id: number;
|
|
24
|
+
type: string;
|
|
25
|
+
props: RemoteProps;
|
|
26
|
+
children: RemoteNode[];
|
|
27
|
+
text?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type RemoteReceiver = {
|
|
31
|
+
apply: (ops: RemoteOp[]) => void;
|
|
32
|
+
/** The root's children are what a tab drew. Mutated in place, so read it after a change. */
|
|
33
|
+
root: RemoteNode;
|
|
34
|
+
/** Bumped on every applied batch, which is what a `useSyncExternalStore` snapshot can watch. */
|
|
35
|
+
version: () => number;
|
|
36
|
+
subscribe: (listener: () => void) => () => void;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export function createRemoteReceiver(): RemoteReceiver {
|
|
40
|
+
const root: RemoteNode = { id: ROOT, type: "#root", props: {}, children: [] };
|
|
41
|
+
const nodes = new Map<number, RemoteNode>([[ROOT, root]]);
|
|
42
|
+
const listeners = new Set<() => void>();
|
|
43
|
+
let version = 0;
|
|
44
|
+
|
|
45
|
+
/** A removed subtree's ids are dropped too, so a later op cannot reach a node that is gone. */
|
|
46
|
+
const forget = (node: RemoteNode): void => {
|
|
47
|
+
nodes.delete(node.id);
|
|
48
|
+
for (const child of node.children) forget(child);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
root,
|
|
53
|
+
version: () => version,
|
|
54
|
+
|
|
55
|
+
subscribe(listener) {
|
|
56
|
+
listeners.add(listener);
|
|
57
|
+
return () => listeners.delete(listener);
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
apply(ops) {
|
|
61
|
+
for (const op of ops) {
|
|
62
|
+
switch (op.op) {
|
|
63
|
+
case "clear":
|
|
64
|
+
// Only what is on screen. React sends this partway through the first commit, after it
|
|
65
|
+
// has already created the nodes it is about to append, so dropping the ids here would
|
|
66
|
+
// throw away the tree it is in the middle of building.
|
|
67
|
+
root.children = [];
|
|
68
|
+
break;
|
|
69
|
+
|
|
70
|
+
case "create":
|
|
71
|
+
nodes.set(op.id, {
|
|
72
|
+
id: op.id,
|
|
73
|
+
type: op.type,
|
|
74
|
+
props: op.props,
|
|
75
|
+
children: [],
|
|
76
|
+
});
|
|
77
|
+
break;
|
|
78
|
+
|
|
79
|
+
case "text":
|
|
80
|
+
nodes.set(op.id, {
|
|
81
|
+
id: op.id,
|
|
82
|
+
type: "#text",
|
|
83
|
+
props: {},
|
|
84
|
+
children: [],
|
|
85
|
+
text: op.text,
|
|
86
|
+
});
|
|
87
|
+
break;
|
|
88
|
+
|
|
89
|
+
case "append": {
|
|
90
|
+
const parent = nodes.get(op.parent);
|
|
91
|
+
const child = nodes.get(op.child);
|
|
92
|
+
if (parent && child) parent.children.push(child);
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
case "insert": {
|
|
97
|
+
const parent = nodes.get(op.parent);
|
|
98
|
+
const child = nodes.get(op.child);
|
|
99
|
+
const before = nodes.get(op.before);
|
|
100
|
+
if (!parent || !child) break;
|
|
101
|
+
// React reorders by inserting a node it has already placed, so this moves rather than
|
|
102
|
+
// copies. Leaving the old position alone put the same node on screen twice.
|
|
103
|
+
const from = parent.children.indexOf(child);
|
|
104
|
+
if (from >= 0) parent.children.splice(from, 1);
|
|
105
|
+
const at = before ? parent.children.indexOf(before) : -1;
|
|
106
|
+
parent.children.splice(
|
|
107
|
+
at < 0 ? parent.children.length : at,
|
|
108
|
+
0,
|
|
109
|
+
child,
|
|
110
|
+
);
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
case "remove": {
|
|
115
|
+
const parent = nodes.get(op.parent);
|
|
116
|
+
const child = nodes.get(op.child);
|
|
117
|
+
if (!parent || !child) break;
|
|
118
|
+
const at = parent.children.indexOf(child);
|
|
119
|
+
if (at >= 0) parent.children.splice(at, 1);
|
|
120
|
+
forget(child);
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
case "update": {
|
|
125
|
+
const node = nodes.get(op.id);
|
|
126
|
+
if (!node) break;
|
|
127
|
+
// A prop React dropped arrives as null rather than by absence, so it is deleted here
|
|
128
|
+
// instead of being handed on as a null the renderer would try to apply.
|
|
129
|
+
const props = { ...node.props };
|
|
130
|
+
for (const [key, value] of Object.entries(op.props)) {
|
|
131
|
+
if (value === null) delete props[key];
|
|
132
|
+
else props[key] = value;
|
|
133
|
+
}
|
|
134
|
+
node.props = props;
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
case "retext": {
|
|
139
|
+
const node = nodes.get(op.id);
|
|
140
|
+
if (node) node.text = op.text;
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
version++;
|
|
147
|
+
for (const listener of listeners) listener();
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { createElement } from "react";
|
|
2
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
ACTION,
|
|
6
|
+
HELLO,
|
|
7
|
+
MUTATE,
|
|
8
|
+
REGISTER,
|
|
9
|
+
} from "../core/constants/message.const";
|
|
10
|
+
import type { TabAction, TabMutation } from "../core/constants/message.const";
|
|
11
|
+
import { createPanelChannel } from "../core/services/panel-channel.service";
|
|
12
|
+
import {
|
|
13
|
+
createTabChannel,
|
|
14
|
+
type TabChannel,
|
|
15
|
+
} from "../core/services/tab-channel.service";
|
|
16
|
+
import { RemoteTree } from "./components/remote-tree.component";
|
|
17
|
+
import { createRemoteReceiver } from "./services/remote-receiver.service";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Boots the page for one tab.
|
|
21
|
+
*
|
|
22
|
+
* Each DevTools tab loads this page with its own id, and the channel is bound to it, so nothing here
|
|
23
|
+
* has to check who a message was for. Nothing here decides what the tab looks like either: the app's
|
|
24
|
+
* React does, its bar included, and this builds the elements it asks for.
|
|
25
|
+
*/
|
|
26
|
+
export function startRenderer(
|
|
27
|
+
tabId: string,
|
|
28
|
+
container: HTMLElement = document.body,
|
|
29
|
+
): TabChannel {
|
|
30
|
+
const channel = createTabChannel(createPanelChannel(), tabId);
|
|
31
|
+
let root: Root | null = null;
|
|
32
|
+
|
|
33
|
+
const mount = () => {
|
|
34
|
+
const receiver = createRemoteReceiver();
|
|
35
|
+
root?.unmount();
|
|
36
|
+
root = createRoot(container);
|
|
37
|
+
root.render(
|
|
38
|
+
createElement(RemoteTree, {
|
|
39
|
+
receiver,
|
|
40
|
+
send: (handler: string, payload: unknown) =>
|
|
41
|
+
channel.send(ACTION, {
|
|
42
|
+
action: handler,
|
|
43
|
+
payload,
|
|
44
|
+
} satisfies TabAction),
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
47
|
+
return receiver;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
let remote = mount();
|
|
51
|
+
|
|
52
|
+
// A registration arriving now means the app is starting over, so what is drawn belongs to an
|
|
53
|
+
// engine that is gone, and the component that drew it went with it. Throwing the tree away is only
|
|
54
|
+
// half of that: the app mounts a tab when it is asked to and nothing asks on its behalf, so asking
|
|
55
|
+
// again here is what gets it drawn a second time. Without it an app reload left every tab that had
|
|
56
|
+
// already been opened blank until its page was reloaded too.
|
|
57
|
+
channel.onMessage(REGISTER, () => {
|
|
58
|
+
remote = mount();
|
|
59
|
+
channel.send(HELLO);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
channel.onMessage(MUTATE, (payload) => {
|
|
63
|
+
remote.apply((payload as TabMutation).ops);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// The app almost always started first, so its registration is already gone. Ask for it rather than
|
|
67
|
+
// waiting for the next one, which is also what makes reloading this page recover.
|
|
68
|
+
channel.send(HELLO);
|
|
69
|
+
|
|
70
|
+
return channel;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type {
|
|
74
|
+
MessageChannel,
|
|
75
|
+
MessageListener,
|
|
76
|
+
} from "../core/services/message-channel.service";
|
|
77
|
+
|
|
78
|
+
export { createPanelChannel } from "../core/services/panel-channel.service";
|
|
79
|
+
export { createTabChannel } from "../core/services/tab-channel.service";
|
|
80
|
+
export type { TabChannel } from "../core/services/tab-channel.service";
|