@vsreact/core 0.0.11 → 0.0.13

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 CHANGED
@@ -1,64 +1,64 @@
1
- {
2
- "name": "@vsreact/core",
3
- "version": "0.0.11",
4
- "description": "Write React. Ship native VST. A React renderer for JUCE audio plugins — QuickJS + Yoga + juce::Graphics, no webview.",
5
- "keywords": [
6
- "react",
7
- "juce",
8
- "vst",
9
- "vst3",
10
- "audio-plugin",
11
- "renderer",
12
- "quickjs",
13
- "yoga",
14
- "native",
15
- "daw"
16
- ],
17
- "type": "module",
18
- "license": "MIT",
19
- "homepage": "https://vsreact.n9records.com",
20
- "bugs": "https://github.com/N9RecordsTechnologiesIL/VSReacT/issues",
21
- "repository": {
22
- "type": "git",
23
- "url": "git+https://github.com/N9RecordsTechnologiesIL/VSReacT.git",
24
- "directory": "vsreact/js"
25
- },
26
- "main": "dist/index.js",
27
- "types": "dist/index.d.ts",
28
- "exports": {
29
- ".": {
30
- "types": "./dist/index.d.ts",
31
- "bun": "./src/index.ts",
32
- "import": "./dist/index.js",
33
- "default": "./dist/index.js"
34
- },
35
- "./components": {
36
- "types": "./dist/components.d.ts",
37
- "bun": "./src/components.ts",
38
- "import": "./dist/components.js",
39
- "default": "./dist/components.js"
40
- }
41
- },
42
- "files": [
43
- "dist",
44
- "src",
45
- "build.ts"
46
- ],
47
- "scripts": {
48
- "test": "bun test",
49
- "typecheck": "tsc --noEmit",
50
- "build": "bun run build.ts",
51
- "build:dist": "tsc -p tsconfig.build.json",
52
- "prepublishOnly": "bun run build:dist"
53
- },
54
- "dependencies": {
55
- "@types/react": "^18.3.3",
56
- "react": "18.3.1",
57
- "react-reconciler": "0.29.2"
58
- },
59
- "devDependencies": {
60
- "@types/react-reconciler": "0.28.8",
61
- "bun-types": "^1.3.14",
62
- "typescript": "^5.5.0"
63
- }
64
- }
1
+ {
2
+ "name": "@vsreact/core",
3
+ "version": "0.0.13",
4
+ "description": "Write React. Ship native VST. A React renderer for JUCE audio plugins — QuickJS + Yoga + juce::Graphics, no webview.",
5
+ "keywords": [
6
+ "react",
7
+ "juce",
8
+ "vst",
9
+ "vst3",
10
+ "audio-plugin",
11
+ "renderer",
12
+ "quickjs",
13
+ "yoga",
14
+ "native",
15
+ "daw"
16
+ ],
17
+ "type": "module",
18
+ "license": "MIT",
19
+ "homepage": "https://vsreact.n9records.com",
20
+ "bugs": "https://github.com/N9RecordsTechnologiesIL/VSReacT/issues",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/N9RecordsTechnologiesIL/VSReacT.git",
24
+ "directory": "vsreact/js"
25
+ },
26
+ "main": "dist/index.js",
27
+ "types": "dist/index.d.ts",
28
+ "exports": {
29
+ ".": {
30
+ "types": "./dist/index.d.ts",
31
+ "bun": "./src/index.ts",
32
+ "import": "./dist/index.js",
33
+ "default": "./dist/index.js"
34
+ },
35
+ "./components": {
36
+ "types": "./dist/components.d.ts",
37
+ "bun": "./src/components.ts",
38
+ "import": "./dist/components.js",
39
+ "default": "./dist/components.js"
40
+ }
41
+ },
42
+ "files": [
43
+ "dist",
44
+ "src",
45
+ "build.ts"
46
+ ],
47
+ "scripts": {
48
+ "test": "bun test",
49
+ "typecheck": "tsc --noEmit",
50
+ "build": "bun run build.ts",
51
+ "build:dist": "tsc -p tsconfig.build.json",
52
+ "prepublishOnly": "bun run build:dist"
53
+ },
54
+ "dependencies": {
55
+ "@types/react": "^18.3.3",
56
+ "react": "18.3.1",
57
+ "react-reconciler": "0.29.2"
58
+ },
59
+ "devDependencies": {
60
+ "@types/react-reconciler": "0.28.8",
61
+ "bun-types": "^1.3.14",
62
+ "typescript": "^5.5.0"
63
+ }
64
+ }
package/src/bridge.ts CHANGED
@@ -1,66 +1,66 @@
1
- // JS side of the mutation/event bridge. Ops queue up during a React commit
2
- // and flush to C++ as one JSON batch; C++ pushes events back through the
3
- // __vsreact_dispatch global registered here.
4
-
5
- import { fireTimer } from "./runtime";
6
-
7
- export type Op = unknown[];
8
-
9
- let queue: Op[] = [];
10
-
11
- export function queueOp(op: Op): void {
12
- queue.push(op);
13
- }
14
-
15
- export function flushOps(): void {
16
- if (queue.length === 0) return;
17
- const json = JSON.stringify(queue);
18
- queue = [];
19
- (globalThis as Record<string, any>).__vsreact_flush?.(json);
20
- }
21
-
22
- export type EventHandler = (payload: unknown) => void;
23
-
24
- const eventHandlers = new Map<number, Map<string, EventHandler>>();
25
-
26
- export function setHandlers(nodeId: number, handlers: Map<string, EventHandler>): void {
27
- if (handlers.size === 0) eventHandlers.delete(nodeId);
28
- else eventHandlers.set(nodeId, handlers);
29
- }
30
-
31
- export function removeHandlers(nodeId: number): void {
32
- eventHandlers.delete(nodeId);
33
- }
34
-
35
- const nativeListeners = new Map<string, Set<EventHandler>>();
36
-
37
- export function addNativeListener(name: string, cb: EventHandler): () => void {
38
- let set = nativeListeners.get(name);
39
- if (!set) nativeListeners.set(name, (set = new Set()));
40
- set.add(cb);
41
- return () => {
42
- set.delete(cb);
43
- if (set.size === 0) nativeListeners.delete(name);
44
- };
45
- }
46
-
47
- interface DispatchMessage {
48
- kind: "event" | "native" | "timer";
49
- nodeId?: number;
50
- type?: string;
51
- name?: string;
52
- id?: number;
53
- payload?: unknown;
54
- }
55
-
56
- (globalThis as Record<string, any>).__vsreact_dispatch = (json: string) => {
57
- const msg = JSON.parse(json) as DispatchMessage;
58
-
59
- if (msg.kind === "timer" && msg.id !== undefined) {
60
- fireTimer(msg.id);
61
- } else if (msg.kind === "event" && msg.nodeId !== undefined && msg.type) {
62
- eventHandlers.get(msg.nodeId)?.get(msg.type)?.(msg.payload);
63
- } else if (msg.kind === "native" && msg.name) {
64
- nativeListeners.get(msg.name)?.forEach((cb) => cb(msg.payload));
65
- }
66
- };
1
+ // JS side of the mutation/event bridge. Ops queue up during a React commit
2
+ // and flush to C++ as one JSON batch; C++ pushes events back through the
3
+ // __vsreact_dispatch global registered here.
4
+
5
+ import { fireTimer } from "./runtime";
6
+
7
+ export type Op = unknown[];
8
+
9
+ let queue: Op[] = [];
10
+
11
+ export function queueOp(op: Op): void {
12
+ queue.push(op);
13
+ }
14
+
15
+ export function flushOps(): void {
16
+ if (queue.length === 0) return;
17
+ const json = JSON.stringify(queue);
18
+ queue = [];
19
+ (globalThis as Record<string, any>).__vsreact_flush?.(json);
20
+ }
21
+
22
+ export type EventHandler = (payload: unknown) => void;
23
+
24
+ const eventHandlers = new Map<number, Map<string, EventHandler>>();
25
+
26
+ export function setHandlers(nodeId: number, handlers: Map<string, EventHandler>): void {
27
+ if (handlers.size === 0) eventHandlers.delete(nodeId);
28
+ else eventHandlers.set(nodeId, handlers);
29
+ }
30
+
31
+ export function removeHandlers(nodeId: number): void {
32
+ eventHandlers.delete(nodeId);
33
+ }
34
+
35
+ const nativeListeners = new Map<string, Set<EventHandler>>();
36
+
37
+ export function addNativeListener(name: string, cb: EventHandler): () => void {
38
+ let set = nativeListeners.get(name);
39
+ if (!set) nativeListeners.set(name, (set = new Set()));
40
+ set.add(cb);
41
+ return () => {
42
+ set.delete(cb);
43
+ if (set.size === 0) nativeListeners.delete(name);
44
+ };
45
+ }
46
+
47
+ interface DispatchMessage {
48
+ kind: "event" | "native" | "timer";
49
+ nodeId?: number;
50
+ type?: string;
51
+ name?: string;
52
+ id?: number;
53
+ payload?: unknown;
54
+ }
55
+
56
+ (globalThis as Record<string, any>).__vsreact_dispatch = (json: string) => {
57
+ const msg = JSON.parse(json) as DispatchMessage;
58
+
59
+ if (msg.kind === "timer" && msg.id !== undefined) {
60
+ fireTimer(msg.id);
61
+ } else if (msg.kind === "event" && msg.nodeId !== undefined && msg.type) {
62
+ eventHandlers.get(msg.nodeId)?.get(msg.type)?.(msg.payload);
63
+ } else if (msg.kind === "native" && msg.name) {
64
+ nativeListeners.get(msg.name)?.forEach((cb) => cb(msg.payload));
65
+ }
66
+ };
package/src/components.ts CHANGED
@@ -64,6 +64,27 @@ export type {
64
64
  } from "./fields";
65
65
  export { ProgressBar, Spinner } from "./feedback";
66
66
  export type { ProgressBarProps, SpinnerProps } from "./feedback";
67
+ export { PianoKeyboard } from "./keyboard";
68
+ export type { PianoKeyboardProps } from "./keyboard";
69
+ export { StepSequencer } from "./sequencer";
70
+ export type { StepSequencerProps } from "./sequencer";
71
+ export {
72
+ ADSREnvelope,
73
+ ParamADSREnvelope,
74
+ PitchBend,
75
+ ModWheel,
76
+ ParamModWheel,
77
+ ParamPitchBend,
78
+ } from "./synth";
79
+ export type {
80
+ ADSRKey,
81
+ ADSREnvelopeProps,
82
+ ParamADSREnvelopeProps,
83
+ PitchBendProps,
84
+ ModWheelProps,
85
+ ParamModWheelProps,
86
+ ParamPitchBendProps,
87
+ } from "./synth";
67
88
  export {
68
89
  MacroPad,
69
90
  ParamMacroPad,
package/src/feedback.tsx CHANGED
@@ -14,6 +14,9 @@ export interface ProgressBarProps {
14
14
  height?: number;
15
15
  /** Renders "42%" alongside the bar. Default false. */
16
16
  showPercent?: boolean;
17
+ /** Unknown duration — a segment sweeps the track and `value` is
18
+ ignored. Default false. */
19
+ indeterminate?: boolean;
17
20
  trackColor?: string;
18
21
  color?: string;
19
22
  label?: string;
@@ -25,24 +28,43 @@ export function ProgressBar({
25
28
  width = 200,
26
29
  height = 8,
27
30
  showPercent = false,
31
+ indeterminate = false,
28
32
  trackColor = "#2A2F27",
29
33
  color = "#C6F135",
30
34
  label,
31
35
  }: ProgressBarProps) {
32
36
  const level = clamp01(value);
33
37
 
38
+ // The indeterminate sweep: a 30%-width segment marching left → right.
39
+ const [sweep, setSweep] = useState(0);
40
+ useInterval(() => setSweep((t) => (t + 0.018) % 1), indeterminate ? 16 : null);
41
+ const segment = width * 0.3;
42
+
34
43
  const bar = (
35
44
  <View className="flex-row items-center gap-3">
36
45
  <View
37
- className="rounded-full overflow-hidden"
46
+ className="rounded-full overflow-hidden relative"
38
47
  style={{ width, height, backgroundColor: trackColor }}
39
48
  >
40
- <View
41
- className="rounded-full"
42
- style={{ width: level * width, height, backgroundColor: color }}
43
- />
49
+ {indeterminate ? (
50
+ <View
51
+ className="rounded-full absolute"
52
+ style={{
53
+ left: sweep * (width + segment) - segment,
54
+ top: 0,
55
+ width: segment,
56
+ height,
57
+ backgroundColor: color,
58
+ }}
59
+ />
60
+ ) : (
61
+ <View
62
+ className="rounded-full"
63
+ style={{ width: level * width, height, backgroundColor: color }}
64
+ />
65
+ )}
44
66
  </View>
45
- {showPercent ? (
67
+ {showPercent && !indeterminate ? (
46
68
  <Text className="text-faint text-[11] font-bold" style={{ width: 34 }}>
47
69
  {`${Math.round(level * 100)}%`}
48
70
  </Text>
package/src/format.ts ADDED
@@ -0,0 +1,73 @@
1
+ // Value formatting for parameter readouts — the strings DAW users
2
+ // expect next to a knob. Pure functions; pair them with NumberBox's
3
+ // `format` prop or any Text.
4
+
5
+ const trim = (value: number, decimals: number) => String(parseFloat(value.toFixed(decimals)));
6
+
7
+ /** Linear interpolation of `value` from [inMin, inMax] to [outMin, outMax].
8
+ `clampOut` pins the result inside the output range. */
9
+ export function mapRange(
10
+ value: number,
11
+ inMin: number,
12
+ inMax: number,
13
+ outMin: number,
14
+ outMax: number,
15
+ clampOut = false,
16
+ ): number {
17
+ const t = inMax === inMin ? 0 : (value - inMin) / (inMax - inMin);
18
+ const out = outMin + t * (outMax - outMin);
19
+ if (!clampOut) return out;
20
+ const lo = Math.min(outMin, outMax);
21
+ const hi = Math.max(outMin, outMax);
22
+ return Math.min(hi, Math.max(lo, out));
23
+ }
24
+
25
+ /** Decibels with an explicit sign: "+6.0 dB", "0.0 dB", "-12.5 dB".
26
+ -Infinity (a gain fader on the floor) reads "-inf dB". */
27
+ export function formatDb(db: number, decimals = 1): string {
28
+ if (db === Number.NEGATIVE_INFINITY) return "-inf dB";
29
+ const text = db.toFixed(decimals);
30
+ return `${db > 0 && !text.startsWith("+") ? "+" : ""}${text} dB`;
31
+ }
32
+
33
+ /** Frequency with automatic kHz: "440 Hz", "1.2 kHz". */
34
+ export function formatHz(hz: number, decimals = 1): string {
35
+ if (Math.abs(hz) >= 1000) return `${trim(hz / 1000, Math.max(decimals, 1))} kHz`;
36
+ return `${trim(hz, decimals)} Hz`;
37
+ }
38
+
39
+ /** Time with automatic seconds: "350 ms", "1.25 s". */
40
+ export function formatMs(ms: number, decimals = 0): string {
41
+ if (Math.abs(ms) >= 1000) return `${trim(ms / 1000, Math.max(decimals, 2))} s`;
42
+ return `${trim(ms, decimals)} ms`;
43
+ }
44
+
45
+ /** A 0..1 value as "42%". */
46
+ export function formatPercent(value01: number, decimals = 0): string {
47
+ return `${trim(value01 * 100, decimals)}%`;
48
+ }
49
+
50
+ /** Semitones with an explicit sign: "+7 st", "0 st", "-12 st". */
51
+ export function formatSemitones(semitones: number, decimals = 0): string {
52
+ const text = trim(semitones, decimals);
53
+ return `${semitones > 0 && !text.startsWith("+") ? "+" : ""}${text} st`;
54
+ }
55
+
56
+ const NOTE_NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
57
+
58
+ /** A MIDI note number as scientific pitch: 60 → "C4", 69 → "A4". */
59
+ export function midiNoteName(note: number): string {
60
+ const n = Math.round(note);
61
+ return `${NOTE_NAMES[((n % 12) + 12) % 12]}${Math.floor(n / 12) - 1}`;
62
+ }
63
+
64
+ /** A MIDI note as a frequency (equal temperament): 69 → 440. Feed your
65
+ oscillator straight from PianoKeyboard's onNoteOn. */
66
+ export function midiNoteToHz(note: number, a4 = 440): number {
67
+ return a4 * Math.pow(2, (note - 69) / 12);
68
+ }
69
+
70
+ /** The nearest MIDI note for a frequency: 440 → 69. */
71
+ export function hzToMidiNote(hz: number, a4 = 440): number {
72
+ return Math.round(69 + 12 * Math.log2(hz / a4));
73
+ }
@@ -1,120 +1,120 @@
1
- import { beforeEach, describe, expect, test } from "bun:test";
2
- import { useState } from "react";
3
-
4
- const batches: unknown[][][] = [];
5
- (globalThis as Record<string, any>).__vsreact_flush = (json: string) => {
6
- batches.push(JSON.parse(json));
7
- };
8
-
9
- import { render, unmount, View, Text, TextInput } from "./index";
10
-
11
- const allOps = () => batches.flat();
12
- const opsNamed = (name: string) => allOps().filter((op: any) => op[0] === name);
13
- const dispatch = (msg: unknown) =>
14
- (globalThis as Record<string, any>).__vsreact_dispatch(JSON.stringify(msg));
15
-
16
- beforeEach(() => {
17
- unmount();
18
- batches.length = 0;
19
- });
20
-
21
- describe("reconciler host config", () => {
22
- test("initial mount emits create/setProps/append ops ending at the root", () => {
23
- render(
24
- <View className="p-4">
25
- <Text>hi</Text>
26
- </View>,
27
- );
28
-
29
- const ops = allOps();
30
- const creates = opsNamed("create");
31
-
32
- // view + text + rawtext
33
- expect(creates.length).toBe(3);
34
- const viewId = (creates.find((op: any) => op[2] === "view") as any)[1];
35
- const textId = (creates.find((op: any) => op[2] === "text") as any)[1];
36
- const rawId = (creates.find((op: any) => op[2] === "rawtext") as any)[1];
37
-
38
- const viewProps: any = opsNamed("setProps").find((op: any) => op[1] === viewId);
39
- expect(viewProps[2].style.padding).toBe(16);
40
-
41
- expect(opsNamed("setText")).toContainEqual(["setText", rawId, "hi"]);
42
- expect(opsNamed("appendChild")).toContainEqual(["appendChild", textId, rawId]);
43
- expect(opsNamed("appendChild")).toContainEqual(["appendChild", viewId, textId]);
44
- expect(opsNamed("appendChild")).toContainEqual(["appendChild", 0, viewId]);
45
-
46
- // root attach comes after the subtree is assembled
47
- const opNames = ops.map((op: any) => op.join(":"));
48
- expect(opNames.indexOf(`appendChild:0:${viewId}`)).toBeGreaterThan(
49
- opNames.indexOf(`appendChild:${viewId}:${textId}`),
50
- );
51
- });
52
-
53
- test("click handlers register as listeners and receive dispatches", () => {
54
- let clicks = 0;
55
-
56
- render(<View onClick={() => clicks++} className="w-4" />);
57
-
58
- const create: any = opsNamed("create")[0];
59
- const props: any = opsNamed("setProps").find((op: any) => op[1] === create[1]);
60
- expect(props[2].listeners).toEqual(["click"]);
61
-
62
- dispatch({ kind: "event", nodeId: create[1], type: "click" });
63
- expect(clicks).toBe(1);
64
- });
65
-
66
- test("state updates emit new setProps and setText", () => {
67
- function Counter() {
68
- const [count, setCount] = useState(0);
69
- return (
70
- <View onClick={() => setCount((c) => c + 1)}>
71
- <Text>{`count:${count}`}</Text>
72
- </View>
73
- );
74
- }
75
-
76
- render(<Counter />);
77
-
78
- const viewId = (opsNamed("create").find((op: any) => op[2] === "view") as any)[1];
79
- batches.length = 0;
80
-
81
- dispatch({ kind: "event", nodeId: viewId, type: "click" });
82
-
83
- expect(opsNamed("setText").some((op: any) => op[2] === "count:1")).toBe(true);
84
- });
85
-
86
- test("TextInput onChange receives the plain string value", () => {
87
- const seen: string[] = [];
88
-
89
- render(<TextInput onChange={(v) => seen.push(v)} />);
90
-
91
- const inputId = (opsNamed("create").find((op: any) => op[2] === "textinput") as any)[1];
92
- dispatch({ kind: "event", nodeId: inputId, type: "change", payload: { value: "abc" } });
93
-
94
- expect(seen).toEqual(["abc"]);
95
- });
96
-
97
- test("conditional removal emits removeChild", () => {
98
- function Toggle({ on }: { on: boolean }) {
99
- return <View>{on ? <View className="w-2" /> : null}</View>;
100
- }
101
-
102
- render(<Toggle on={true} />);
103
- // React creates instances bottom-up: the inner view is created first.
104
- const childId = (opsNamed("create")[0] as any)[1];
105
- batches.length = 0;
106
-
107
- render(<Toggle on={false} />);
108
- expect(opsNamed("removeChild").some((op: any) => op[2] === childId)).toBe(true);
109
- });
110
-
111
- test("unmount clears the container", () => {
112
- render(<View />);
113
- batches.length = 0;
114
- unmount();
115
- const ops = allOps();
116
- expect(ops.some((op: any) => op[0] === "removeChild" || op[0] === "clearContainer")).toBe(
117
- true,
118
- );
119
- });
120
- });
1
+ import { beforeEach, describe, expect, test } from "bun:test";
2
+ import { useState } from "react";
3
+
4
+ const batches: unknown[][][] = [];
5
+ (globalThis as Record<string, any>).__vsreact_flush = (json: string) => {
6
+ batches.push(JSON.parse(json));
7
+ };
8
+
9
+ import { render, unmount, View, Text, TextInput } from "./index";
10
+
11
+ const allOps = () => batches.flat();
12
+ const opsNamed = (name: string) => allOps().filter((op: any) => op[0] === name);
13
+ const dispatch = (msg: unknown) =>
14
+ (globalThis as Record<string, any>).__vsreact_dispatch(JSON.stringify(msg));
15
+
16
+ beforeEach(() => {
17
+ unmount();
18
+ batches.length = 0;
19
+ });
20
+
21
+ describe("reconciler host config", () => {
22
+ test("initial mount emits create/setProps/append ops ending at the root", () => {
23
+ render(
24
+ <View className="p-4">
25
+ <Text>hi</Text>
26
+ </View>,
27
+ );
28
+
29
+ const ops = allOps();
30
+ const creates = opsNamed("create");
31
+
32
+ // view + text + rawtext
33
+ expect(creates.length).toBe(3);
34
+ const viewId = (creates.find((op: any) => op[2] === "view") as any)[1];
35
+ const textId = (creates.find((op: any) => op[2] === "text") as any)[1];
36
+ const rawId = (creates.find((op: any) => op[2] === "rawtext") as any)[1];
37
+
38
+ const viewProps: any = opsNamed("setProps").find((op: any) => op[1] === viewId);
39
+ expect(viewProps[2].style.padding).toBe(16);
40
+
41
+ expect(opsNamed("setText")).toContainEqual(["setText", rawId, "hi"]);
42
+ expect(opsNamed("appendChild")).toContainEqual(["appendChild", textId, rawId]);
43
+ expect(opsNamed("appendChild")).toContainEqual(["appendChild", viewId, textId]);
44
+ expect(opsNamed("appendChild")).toContainEqual(["appendChild", 0, viewId]);
45
+
46
+ // root attach comes after the subtree is assembled
47
+ const opNames = ops.map((op: any) => op.join(":"));
48
+ expect(opNames.indexOf(`appendChild:0:${viewId}`)).toBeGreaterThan(
49
+ opNames.indexOf(`appendChild:${viewId}:${textId}`),
50
+ );
51
+ });
52
+
53
+ test("click handlers register as listeners and receive dispatches", () => {
54
+ let clicks = 0;
55
+
56
+ render(<View onClick={() => clicks++} className="w-4" />);
57
+
58
+ const create: any = opsNamed("create")[0];
59
+ const props: any = opsNamed("setProps").find((op: any) => op[1] === create[1]);
60
+ expect(props[2].listeners).toEqual(["click"]);
61
+
62
+ dispatch({ kind: "event", nodeId: create[1], type: "click" });
63
+ expect(clicks).toBe(1);
64
+ });
65
+
66
+ test("state updates emit new setProps and setText", () => {
67
+ function Counter() {
68
+ const [count, setCount] = useState(0);
69
+ return (
70
+ <View onClick={() => setCount((c) => c + 1)}>
71
+ <Text>{`count:${count}`}</Text>
72
+ </View>
73
+ );
74
+ }
75
+
76
+ render(<Counter />);
77
+
78
+ const viewId = (opsNamed("create").find((op: any) => op[2] === "view") as any)[1];
79
+ batches.length = 0;
80
+
81
+ dispatch({ kind: "event", nodeId: viewId, type: "click" });
82
+
83
+ expect(opsNamed("setText").some((op: any) => op[2] === "count:1")).toBe(true);
84
+ });
85
+
86
+ test("TextInput onChange receives the plain string value", () => {
87
+ const seen: string[] = [];
88
+
89
+ render(<TextInput onChange={(v) => seen.push(v)} />);
90
+
91
+ const inputId = (opsNamed("create").find((op: any) => op[2] === "textinput") as any)[1];
92
+ dispatch({ kind: "event", nodeId: inputId, type: "change", payload: { value: "abc" } });
93
+
94
+ expect(seen).toEqual(["abc"]);
95
+ });
96
+
97
+ test("conditional removal emits removeChild", () => {
98
+ function Toggle({ on }: { on: boolean }) {
99
+ return <View>{on ? <View className="w-2" /> : null}</View>;
100
+ }
101
+
102
+ render(<Toggle on={true} />);
103
+ // React creates instances bottom-up: the inner view is created first.
104
+ const childId = (opsNamed("create")[0] as any)[1];
105
+ batches.length = 0;
106
+
107
+ render(<Toggle on={false} />);
108
+ expect(opsNamed("removeChild").some((op: any) => op[2] === childId)).toBe(true);
109
+ });
110
+
111
+ test("unmount clears the container", () => {
112
+ render(<View />);
113
+ batches.length = 0;
114
+ unmount();
115
+ const ops = allOps();
116
+ expect(ops.some((op: any) => op[0] === "removeChild" || op[0] === "clearContainer")).toBe(
117
+ true,
118
+ );
119
+ });
120
+ });