@rozenite/controls-plugin 1.10.0 → 1.12.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.
@@ -127,8 +127,12 @@ export declare type RozeniteControlsPluginOptions = {
127
127
  sections: ControlsSection[];
128
128
  };
129
129
 
130
+ export declare type RozeniteControlsPluginOptionsInput = RozeniteControlsPluginOptions | RozeniteControlsPluginOptionsUpdater;
131
+
132
+ export declare type RozeniteControlsPluginOptionsUpdater = (previousOptions: RozeniteControlsPluginOptions) => RozeniteControlsPluginOptions;
133
+
130
134
  export declare let useRozeniteControlsPlugin: typeof useRozeniteControlsPlugin_2;
131
135
 
132
- declare const useRozeniteControlsPlugin_2: ({ sections, }: RozeniteControlsPluginOptions) => RozeniteDevToolsClient<ControlsEventMap> | null;
136
+ declare const useRozeniteControlsPlugin_2: (optionsInput: RozeniteControlsPluginOptionsInput) => RozeniteDevToolsClient<ControlsEventMap> | null;
133
137
 
134
138
  export { }
@@ -1 +1 @@
1
- {"name":"@rozenite/controls-plugin","version":"1.10.0","description":"A Rozenite plugin for exposing app-defined controls directly in React Native DevTools.","panels":[{"name":"Controls","source":"/devtools/panel.html"}]}
1
+ {"name":"@rozenite/controls-plugin","version":"1.12.0","description":"A Rozenite plugin for exposing app-defined controls directly in React Native DevTools.","panels":[{"name":"Controls","source":"/devtools/panel.html"}]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rozenite/controls-plugin",
3
- "version": "1.10.0",
3
+ "version": "1.12.0",
4
4
  "description": "A Rozenite plugin for exposing app-defined controls directly in React Native DevTools.",
5
5
  "type": "module",
6
6
  "main": "./dist/react-native/index.cjs",
@@ -15,9 +15,9 @@
15
15
  "url": "https://github.com/callstackincubator/rozenite.git"
16
16
  },
17
17
  "dependencies": {
18
- "@rozenite/agent-shared": "1.10.0",
19
- "@rozenite/agent-bridge": "1.10.0",
20
- "@rozenite/plugin-bridge": "1.10.0"
18
+ "@rozenite/agent-shared": "1.12.0",
19
+ "@rozenite/agent-bridge": "1.12.0",
20
+ "@rozenite/plugin-bridge": "1.12.0"
21
21
  },
22
22
  "devDependencies": {
23
23
  "@types/react": "~19.2.2",
@@ -32,8 +32,8 @@
32
32
  "tailwindcss-animate": "^1.0.7",
33
33
  "typescript": "~5.9.3",
34
34
  "vite": "^7.3.1",
35
- "@rozenite/vite-plugin": "1.10.0",
36
- "rozenite": "1.10.0"
35
+ "@rozenite/vite-plugin": "1.12.0",
36
+ "rozenite": "1.12.0"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "react": "*",
@@ -61,6 +61,7 @@
61
61
  "build": "rozenite build",
62
62
  "dev": "rozenite dev",
63
63
  "typecheck": "tsc -p tsconfig.json --noEmit",
64
- "lint": "eslint ."
64
+ "lint": "eslint .",
65
+ "test": "vitest --run --passWithNoTests"
65
66
  }
66
67
  }
package/react-native.d.ts CHANGED
@@ -8,12 +8,16 @@ export {
8
8
  type ControlsSection,
9
9
  type ControlsTextItem,
10
10
  type ControlsToggleItem,
11
+ type RozeniteControlsPluginOptionsInput,
11
12
  type ControlsValidationResult,
12
13
  type RozeniteControlsPluginOptions,
14
+ type RozeniteControlsPluginOptionsUpdater,
13
15
  } from './src/shared/types';
14
16
 
15
17
  export declare const useRozeniteControlsPlugin: (
16
- options: import('./src/shared/types').RozeniteControlsPluginOptions
17
- ) => import('@rozenite/plugin-bridge').RozeniteDevToolsClient<
18
- import('./src/shared/messaging').ControlsEventMap
19
- > | null;
18
+ options: import('./src/shared/types').RozeniteControlsPluginOptionsInput,
19
+ ) =>
20
+ | import('@rozenite/plugin-bridge').RozeniteDevToolsClient<
21
+ import('./src/shared/messaging').ControlsEventMap
22
+ >
23
+ | null;
package/react-native.ts CHANGED
@@ -8,8 +8,10 @@ export {
8
8
  type ControlsSection,
9
9
  type ControlsTextItem,
10
10
  type ControlsToggleItem,
11
+ type RozeniteControlsPluginOptionsInput,
11
12
  type ControlsValidationResult,
12
13
  type RozeniteControlsPluginOptions,
14
+ type RozeniteControlsPluginOptionsUpdater,
13
15
  } from './src/shared/types';
14
16
 
15
17
  export let useRozeniteControlsPlugin: typeof import('./src/react-native/useRozeniteControlsPlugin').useRozeniteControlsPlugin;
@@ -0,0 +1,92 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { createControlsRegistry } from '../controlsRegistry';
3
+ import type { ControlsSection } from '../../shared/types';
4
+
5
+ const section = (id: string): ControlsSection => ({
6
+ id,
7
+ title: id,
8
+ items: [],
9
+ });
10
+
11
+ describe('controlsRegistry', () => {
12
+ it('combines section registrations in registration order', () => {
13
+ const registry = createControlsRegistry();
14
+ const first = Symbol('first');
15
+ const second = Symbol('second');
16
+
17
+ registry.set(first, { sections: [section('app')] });
18
+ registry.set(second, { sections: [section('locale')] });
19
+
20
+ expect(registry.getOptions().sections.map(({ id }) => id)).toEqual([
21
+ 'app',
22
+ 'locale',
23
+ ]);
24
+ });
25
+
26
+ it('lets updater registrations derive from previous options', () => {
27
+ const registry = createControlsRegistry();
28
+ const first = Symbol('first');
29
+ const second = Symbol('second');
30
+
31
+ registry.set(first, { sections: [section('app')] });
32
+ registry.set(second, (previousOptions) => ({
33
+ sections: [...previousOptions.sections, section('locale')],
34
+ }));
35
+
36
+ expect(registry.getOptions().sections.map(({ id }) => id)).toEqual([
37
+ 'app',
38
+ 'locale',
39
+ ]);
40
+ });
41
+
42
+ it('lets updater registrations replace previous options when needed', () => {
43
+ const registry = createControlsRegistry();
44
+ const first = Symbol('first');
45
+ const second = Symbol('second');
46
+
47
+ registry.set(first, { sections: [section('app')] });
48
+ registry.set(second, () => ({
49
+ sections: [section('replacement')],
50
+ }));
51
+
52
+ expect(registry.getOptions().sections.map(({ id }) => id)).toEqual([
53
+ 'replacement',
54
+ ]);
55
+ });
56
+
57
+ it('can include the current render input before the effect registers it', () => {
58
+ const registry = createControlsRegistry();
59
+ const id = Symbol('pending');
60
+
61
+ expect(
62
+ registry
63
+ .getOptions({ id, input: { sections: [section('pending')] } })
64
+ .sections.map((registeredSection) => registeredSection.id),
65
+ ).toEqual(['pending']);
66
+ expect(registry.isOwner(id, { id, input: { sections: [] } })).toBe(true);
67
+ });
68
+
69
+ it('notifies subscribers when ownership changes or registrations are removed', () => {
70
+ const registry = createControlsRegistry();
71
+ const listener = vi.fn();
72
+ const first = Symbol('first');
73
+ const second = Symbol('second');
74
+
75
+ registry.subscribe(listener);
76
+ expect(registry.getSnapshot()).toBe(0);
77
+
78
+ registry.set(first, { sections: [section('app')] });
79
+ expect(registry.getSnapshot()).toBe(1);
80
+
81
+ registry.set(second, { sections: [section('locale')] });
82
+ expect(registry.getSnapshot()).toBe(1);
83
+
84
+ registry.delete(second);
85
+ expect(registry.getSnapshot()).toBe(2);
86
+
87
+ registry.delete(first);
88
+ expect(registry.getSnapshot()).toBe(3);
89
+
90
+ expect(listener).toHaveBeenCalledTimes(3);
91
+ });
92
+ });
@@ -0,0 +1,211 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act, type ReactNode } from 'react';
4
+ import { createRoot, type Root } from 'react-dom/client';
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6
+ import { createSection } from '../../shared/types';
7
+ import type { ControlsEventMap } from '../../shared/messaging';
8
+
9
+ declare global {
10
+ var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
11
+ }
12
+
13
+ type Listener = (payload: unknown) => void;
14
+
15
+ const mocks = vi.hoisted(() => {
16
+ const listeners = new Map<string, Set<Listener>>();
17
+ const send = vi.fn();
18
+ const onMessage = vi.fn((type: string, listener: Listener) => {
19
+ const typeListeners = listeners.get(type) ?? new Set<Listener>();
20
+ typeListeners.add(listener);
21
+ listeners.set(type, typeListeners);
22
+
23
+ return {
24
+ remove: () => {
25
+ typeListeners.delete(listener);
26
+ },
27
+ };
28
+ });
29
+
30
+ return {
31
+ client: {
32
+ send,
33
+ onMessage,
34
+ },
35
+ emit: (type: keyof ControlsEventMap, payload: unknown) => {
36
+ listeners.get(type)?.forEach((listener) => {
37
+ listener(payload);
38
+ });
39
+ },
40
+ reset: () => {
41
+ listeners.clear();
42
+ send.mockReset();
43
+ onMessage.mockClear();
44
+ },
45
+ };
46
+ });
47
+
48
+ vi.mock('@rozenite/plugin-bridge', () => ({
49
+ useRozeniteDevToolsClient: () => mocks.client,
50
+ }));
51
+
52
+ vi.mock('../useControlsAgentTools', () => ({
53
+ useControlsAgentTools: vi.fn(),
54
+ }));
55
+
56
+ import { useRozeniteControlsPlugin } from '../useRozeniteControlsPlugin';
57
+
58
+ const appSection = createSection({
59
+ id: 'app',
60
+ title: 'App',
61
+ items: [],
62
+ });
63
+
64
+ const localeSection = (onUpdate: (value: string) => void = vi.fn()) =>
65
+ createSection({
66
+ id: 'locale',
67
+ title: 'Locale',
68
+ items: [
69
+ {
70
+ id: 'language',
71
+ type: 'select',
72
+ title: 'Language',
73
+ value: 'en',
74
+ options: [
75
+ { label: 'English', value: 'en' },
76
+ { label: 'Polish', value: 'pl' },
77
+ ],
78
+ onUpdate,
79
+ },
80
+ ],
81
+ });
82
+
83
+ function AppControls() {
84
+ useRozeniteControlsPlugin({
85
+ sections: [appSection],
86
+ });
87
+
88
+ return null;
89
+ }
90
+
91
+ function LocaleControls({
92
+ onUpdate = vi.fn(),
93
+ }: {
94
+ onUpdate?: (value: string) => void;
95
+ }) {
96
+ useRozeniteControlsPlugin((previousOptions) => ({
97
+ sections: [...previousOptions.sections, localeSection(onUpdate)],
98
+ }));
99
+
100
+ return null;
101
+ }
102
+
103
+ function TestApp({
104
+ showLocale = true,
105
+ onUpdate,
106
+ }: {
107
+ showLocale?: boolean;
108
+ onUpdate?: (value: string) => void;
109
+ }) {
110
+ return (
111
+ <>
112
+ <AppControls />
113
+ {showLocale ? <LocaleControls onUpdate={onUpdate} /> : null}
114
+ </>
115
+ );
116
+ }
117
+
118
+ const renderControls = async (
119
+ element: ReactNode,
120
+ ): Promise<{
121
+ root: Root;
122
+ container: HTMLDivElement;
123
+ }> => {
124
+ const container = document.createElement('div');
125
+ document.body.appendChild(container);
126
+ const root = createRoot(container);
127
+
128
+ await act(async () => {
129
+ root.render(element);
130
+ });
131
+
132
+ return { root, container };
133
+ };
134
+
135
+ const unmountControls = async (root: Root, container: HTMLDivElement) => {
136
+ await act(async () => {
137
+ root.unmount();
138
+ });
139
+ container.remove();
140
+ };
141
+
142
+ const getLastSnapshotSectionIds = () => {
143
+ const snapshots = mocks.client.send.mock.calls.filter(
144
+ ([type]) => type === 'snapshot',
145
+ );
146
+ const lastSnapshot = snapshots.at(-1)?.[1] as
147
+ | ControlsEventMap['snapshot']
148
+ | undefined;
149
+
150
+ return lastSnapshot?.sections.map((section) => section.id);
151
+ };
152
+
153
+ describe('useRozeniteControlsPlugin', () => {
154
+ beforeEach(() => {
155
+ mocks.reset();
156
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true;
157
+ });
158
+
159
+ afterEach(() => {
160
+ document.body.innerHTML = '';
161
+ delete globalThis.IS_REACT_ACT_ENVIRONMENT;
162
+ });
163
+
164
+ it('publishes a combined snapshot from multiple hook instances', async () => {
165
+ const { root, container } = await renderControls(<TestApp />);
166
+
167
+ expect(getLastSnapshotSectionIds()).toEqual(['app', 'locale']);
168
+
169
+ await unmountControls(root, container);
170
+ });
171
+
172
+ it('removes sections when their hook instance unmounts', async () => {
173
+ const { root, container } = await renderControls(<TestApp />);
174
+
175
+ await act(async () => {
176
+ root.render(<TestApp showLocale={false} />);
177
+ });
178
+
179
+ expect(getLastSnapshotSectionIds()).toEqual(['app']);
180
+
181
+ await unmountControls(root, container);
182
+ });
183
+
184
+ it('routes updates to sections registered by another hook instance', async () => {
185
+ const onUpdate = vi.fn();
186
+ const { root, container } = await renderControls(
187
+ <TestApp onUpdate={onUpdate} />,
188
+ );
189
+
190
+ await act(async () => {
191
+ mocks.emit('update-request', {
192
+ type: 'update-request',
193
+ requestId: 'request-1',
194
+ sectionId: 'locale',
195
+ itemId: 'language',
196
+ value: 'pl',
197
+ });
198
+ });
199
+
200
+ expect(onUpdate).toHaveBeenCalledWith('pl');
201
+ expect(mocks.client.send).toHaveBeenCalledWith('update-result', {
202
+ type: 'update-result',
203
+ requestId: 'request-1',
204
+ sectionId: 'locale',
205
+ itemId: 'language',
206
+ status: 'ok',
207
+ });
208
+
209
+ await unmountControls(root, container);
210
+ });
211
+ });
@@ -0,0 +1,101 @@
1
+ import type {
2
+ RozeniteControlsPluginOptions,
3
+ RozeniteControlsPluginOptionsInput,
4
+ } from '../shared/types';
5
+
6
+ type ControlsRegistryListener = () => void;
7
+ type ControlsRegistryOverride = {
8
+ id: symbol;
9
+ input: RozeniteControlsPluginOptionsInput;
10
+ };
11
+
12
+ const EMPTY_OPTIONS: RozeniteControlsPluginOptions = {
13
+ sections: [],
14
+ };
15
+
16
+ const resolveOptionsInput = (
17
+ previousOptions: RozeniteControlsPluginOptions,
18
+ input: RozeniteControlsPluginOptionsInput,
19
+ ): RozeniteControlsPluginOptions => {
20
+ if (typeof input === 'function') {
21
+ return input(previousOptions);
22
+ }
23
+
24
+ return {
25
+ ...previousOptions,
26
+ ...input,
27
+ sections: [...previousOptions.sections, ...input.sections],
28
+ };
29
+ };
30
+
31
+ export const createControlsRegistry = () => {
32
+ const registrations = new Map<symbol, RozeniteControlsPluginOptionsInput>();
33
+ const listeners = new Set<ControlsRegistryListener>();
34
+ let snapshot = 0;
35
+
36
+ const getOwnerId = () => registrations.keys().next().value;
37
+
38
+ const notify = () => {
39
+ snapshot += 1;
40
+ listeners.forEach((listener) => listener());
41
+ };
42
+
43
+ const getRegistrationEntries = (override?: ControlsRegistryOverride) => {
44
+ const entries = Array.from(registrations.entries());
45
+
46
+ if (!override) {
47
+ return entries;
48
+ }
49
+
50
+ const overrideIndex = entries.findIndex(([id]) => id === override.id);
51
+
52
+ if (overrideIndex === -1) {
53
+ return [...entries, [override.id, override.input] as const];
54
+ }
55
+
56
+ return entries.map(([id, input]) =>
57
+ id === override.id
58
+ ? ([id, override.input] as const)
59
+ : ([id, input] as const),
60
+ );
61
+ };
62
+
63
+ return {
64
+ set(id: symbol, input: RozeniteControlsPluginOptionsInput) {
65
+ const previousOwnerId = getOwnerId();
66
+ registrations.set(id, input);
67
+
68
+ if (previousOwnerId !== getOwnerId()) {
69
+ notify();
70
+ }
71
+ },
72
+ delete(id: symbol) {
73
+ registrations.delete(id);
74
+ notify();
75
+ },
76
+ getOptions(
77
+ override?: ControlsRegistryOverride,
78
+ ): RozeniteControlsPluginOptions {
79
+ return getRegistrationEntries(override)
80
+ .map(([, input]) => input)
81
+ .reduce(resolveOptionsInput, EMPTY_OPTIONS);
82
+ },
83
+ isOwner(id: symbol, override?: ControlsRegistryOverride) {
84
+ const ownerEntry = getRegistrationEntries(override)[0];
85
+
86
+ return ownerEntry?.[0] === id;
87
+ },
88
+ getSnapshot() {
89
+ return snapshot;
90
+ },
91
+ subscribe(listener: ControlsRegistryListener) {
92
+ listeners.add(listener);
93
+
94
+ return () => {
95
+ listeners.delete(listener);
96
+ };
97
+ },
98
+ };
99
+ };
100
+
101
+ export const controlsRegistry = createControlsRegistry();
@@ -8,14 +8,14 @@ import type { ControlsSection } from '../shared/types';
8
8
  const resolveItem = (
9
9
  sections: ControlsSection[],
10
10
  sectionId: string,
11
- itemId: string
11
+ itemId: string,
12
12
  ) => {
13
13
  const section = sections.find((s) => s.id === sectionId);
14
14
 
15
15
  if (!section) {
16
16
  const available = sections.map((s) => s.id).join(', ');
17
17
  throw new Error(
18
- `Section "${sectionId}" not found. Available: ${available || '(none)'}`
18
+ `Section "${sectionId}" not found. Available: ${available || '(none)'}`,
19
19
  );
20
20
  }
21
21
 
@@ -24,19 +24,23 @@ const resolveItem = (
24
24
  if (!item) {
25
25
  const available = section.items.map((i) => i.id).join(', ');
26
26
  throw new Error(
27
- `Item "${itemId}" not found in section "${sectionId}". Available: ${available || '(none)'}`
27
+ `Item "${itemId}" not found in section "${sectionId}". Available: ${available || '(none)'}`,
28
28
  );
29
29
  }
30
30
 
31
31
  return { section, item };
32
32
  };
33
33
 
34
- export const useControlsAgentTools = (sections: ControlsSection[]) => {
34
+ export const useControlsAgentTools = (
35
+ getSections: () => ControlsSection[],
36
+ enabled = true,
37
+ ) => {
35
38
  useRozenitePluginAgentTool({
36
39
  pluginId: CONTROLS_AGENT_PLUGIN_ID,
37
40
  tool: controlsToolDefinitions.listSections,
41
+ enabled,
38
42
  handler: () => ({
39
- sections: sections.map((section) => ({
43
+ sections: getSections().map((section) => ({
40
44
  id: section.id,
41
45
  title: section.title,
42
46
  description: section.description,
@@ -53,8 +57,9 @@ export const useControlsAgentTools = (sections: ControlsSection[]) => {
53
57
  useRozenitePluginAgentTool({
54
58
  pluginId: CONTROLS_AGENT_PLUGIN_ID,
55
59
  tool: controlsToolDefinitions.getItem,
60
+ enabled,
56
61
  handler: ({ sectionId, itemId }) => {
57
- const { item } = resolveItem(sections, sectionId, itemId);
62
+ const { item } = resolveItem(getSections(), sectionId, itemId);
58
63
 
59
64
  if (item.type === 'text') {
60
65
  return {
@@ -131,18 +136,19 @@ export const useControlsAgentTools = (sections: ControlsSection[]) => {
131
136
  useRozenitePluginAgentTool({
132
137
  pluginId: CONTROLS_AGENT_PLUGIN_ID,
133
138
  tool: controlsToolDefinitions.setValue,
139
+ enabled,
134
140
  handler: async ({ sectionId, itemId, value }) => {
135
- const { item } = resolveItem(sections, sectionId, itemId);
141
+ const { item } = resolveItem(getSections(), sectionId, itemId);
136
142
 
137
143
  if (item.type === 'text') {
138
144
  throw new Error(
139
- `Item "${itemId}" is a read-only text item and cannot be updated.`
145
+ `Item "${itemId}" is a read-only text item and cannot be updated.`,
140
146
  );
141
147
  }
142
148
 
143
149
  if (item.type === 'button') {
144
150
  throw new Error(
145
- `Item "${itemId}" is a button. Use press-button to trigger its action.`
151
+ `Item "${itemId}" is a button. Use press-button to trigger its action.`,
146
152
  );
147
153
  }
148
154
 
@@ -153,7 +159,7 @@ export const useControlsAgentTools = (sections: ControlsSection[]) => {
153
159
  if (item.type === 'toggle') {
154
160
  if (typeof value !== 'boolean') {
155
161
  throw new Error(
156
- `Expected boolean value for toggle item "${itemId}".`
162
+ `Expected boolean value for toggle item "${itemId}".`,
157
163
  );
158
164
  }
159
165
 
@@ -170,7 +176,7 @@ export const useControlsAgentTools = (sections: ControlsSection[]) => {
170
176
 
171
177
  if (typeof value !== 'string') {
172
178
  throw new Error(
173
- `Expected string value for ${item.type} item "${itemId}".`
179
+ `Expected string value for ${item.type} item "${itemId}".`,
174
180
  );
175
181
  }
176
182
 
@@ -178,7 +184,7 @@ export const useControlsAgentTools = (sections: ControlsSection[]) => {
178
184
  const validOptions = item.options.map((o) => o.value);
179
185
  if (!validOptions.includes(value)) {
180
186
  throw new Error(
181
- `Invalid option "${value}" for item "${itemId}". Valid options: ${validOptions.join(', ')}`
187
+ `Invalid option "${value}" for item "${itemId}". Valid options: ${validOptions.join(', ')}`,
182
188
  );
183
189
  }
184
190
  }
@@ -198,12 +204,13 @@ export const useControlsAgentTools = (sections: ControlsSection[]) => {
198
204
  useRozenitePluginAgentTool({
199
205
  pluginId: CONTROLS_AGENT_PLUGIN_ID,
200
206
  tool: controlsToolDefinitions.pressButton,
207
+ enabled,
201
208
  handler: async ({ sectionId, itemId }) => {
202
- const { item } = resolveItem(sections, sectionId, itemId);
209
+ const { item } = resolveItem(getSections(), sectionId, itemId);
203
210
 
204
211
  if (item.type !== 'button') {
205
212
  throw new Error(
206
- `Item "${itemId}" is not a button (type: ${item.type}). Use set-value to update its value.`
213
+ `Item "${itemId}" is not a button (type: ${item.type}). Use set-value to update its value.`,
207
214
  );
208
215
  }
209
216