@ompchamber/ui 2.0.0 → 2.0.1

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@ompchamber/ui",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
4
4
  "type": "module",
5
5
  "description": "Preact hooks and components for OMPChamber panel plugins.",
6
6
  "exports": {
@@ -12,7 +12,7 @@
12
12
  ],
13
13
  "dependencies": {
14
14
  "preact": "^10.29.8",
15
- "@ompchamber/plugin-sdk": "1.0.0"
15
+ "@ompchamber/plugin-sdk": "2.0.0"
16
16
  },
17
17
  "license": "UNLICENSED",
18
18
  "repository": {
package/src/index.tsx CHANGED
@@ -22,7 +22,7 @@
22
22
  * every tab switch. `useSessionValue` reads the chamber's store.
23
23
  */
24
24
 
25
- import { useCallback, useEffect, useState } from 'preact/hooks';
25
+ import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
26
26
 
27
27
  /** What the host seeds every plugin component with. */
28
28
  export interface PanelContext {
@@ -47,6 +47,21 @@ export interface UiKitServices {
47
47
  getSessionValue(sessionId: string | null, key: string): string | null;
48
48
  /** Write one per-session value. */
49
49
  setSessionValue(sessionId: string | null, key: string, value: string): void;
50
+ /**
51
+ * Subscribe to writes of ONE per-session value, from any component.
52
+ *
53
+ * Optional so a host built before this seam still runs a newer kit — the
54
+ * hook degrades to read-on-mount, which is what every host did.
55
+ *
56
+ * It exists because `getSessionValue` alone makes two readers of one key
57
+ * disagree: a component reads the store once and then holds its own copy, so
58
+ * a field that WRITES a value and a readout that DISPLAYS it are two private
59
+ * copies of the same fact, and the readout keeps the value it read at mount
60
+ * forever. That is not a stale pixel — it is the plugin's own UI contradicting
61
+ * itself, which is exactly what the bundled example does (a note field and a
62
+ * character count of that note).
63
+ */
64
+ subscribeSessionValue?(sessionId: string | null, key: string, listener: () => void): () => void;
50
65
  /** Read a text file inside the active workspace. Rejects with the reason. */
51
66
  readWorkspaceFile(workspacePath: string | null, relativePath: string): Promise<string>;
52
67
  }
@@ -109,23 +124,49 @@ export function useSessionValue(key: string, delayMs = 400): SessionValue {
109
124
  const [value, setValue] = useState<string | null>(null);
110
125
  const [status, setStatus] = useState<SessionValue['status']>('loading');
111
126
  const [error, setError] = useState<string | null>(null);
127
+ // Distinguishes "this component's own edit" from "someone else wrote the
128
+ // slot". Only the former may set `saving`, or an unrelated write would mark a
129
+ // field dirty and write it straight back.
130
+ const editing = useRef(false);
112
131
 
113
- useEffect(() => {
114
- setStatus('loading');
132
+ const read = useCallback((): string => {
115
133
  try {
116
- setValue(requireServices().getSessionValue(sessionId, key) ?? '');
117
- setStatus('idle');
134
+ const stored = requireServices().getSessionValue(sessionId, key) ?? '';
135
+ setError(null);
136
+ return stored;
118
137
  } catch (cause) {
119
138
  setError(cause instanceof Error ? cause.message : String(cause));
120
139
  setStatus('error');
140
+ return '';
121
141
  }
122
142
  }, [sessionId, key]);
123
143
 
144
+ useEffect(() => {
145
+ editing.current = false;
146
+ setStatus('loading');
147
+ setValue(read());
148
+ setStatus((current) => (current === 'error' ? current : 'idle'));
149
+ }, [read]);
150
+
151
+ // Follow writes from anywhere else — the header readout showing a note's
152
+ // length, a second view of the same value. Skipped while THIS component is
153
+ // mid-edit, because its own state is the newer truth until the debounce
154
+ // lands.
155
+ useEffect(() => {
156
+ const api = requireServices();
157
+ if (!api.subscribeSessionValue) return;
158
+ return api.subscribeSessionValue(sessionId, key, () => {
159
+ if (editing.current) return;
160
+ setValue(read());
161
+ });
162
+ }, [sessionId, key, read]);
163
+
124
164
  useEffect(() => {
125
165
  if (value === null || status !== 'saving') return;
126
166
  const timer = setTimeout(() => {
127
167
  try {
128
168
  requireServices().setSessionValue(sessionId, key, value);
169
+ editing.current = false;
129
170
  setStatus('saved');
130
171
  } catch (cause) {
131
172
  setError(cause instanceof Error ? cause.message : String(cause));
@@ -136,6 +177,7 @@ export function useSessionValue(key: string, delayMs = 400): SessionValue {
136
177
  }, [sessionId, key, value, status, delayMs]);
137
178
 
138
179
  const update = useCallback((next: string) => {
180
+ editing.current = true;
139
181
  setValue(next);
140
182
  setStatus('saving');
141
183
  }, []);
@@ -0,0 +1,162 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * `useSessionValue` and the components that read the same slot.
8
+ *
9
+ * The defect this pins: the hook read the store ONCE per mount and then held a
10
+ * private copy, so two components reading one key never saw each other's
11
+ * writes. A field that WRITES a value and a readout that DISPLAYS it therefore
12
+ * disagreed permanently — the readout kept whatever it read at mount while the
13
+ * field showed what the user had typed. That is the bundled example plugin's
14
+ * own shape (a note field and a character count of that note), so the plugin
15
+ * contradicted itself on screen.
16
+ *
17
+ * Two rules the fix must keep:
18
+ *
19
+ * - a write by ANOTHER component re-reads the slot and re-renders;
20
+ * - a write by THIS component does NOT: its own state is the newer truth until
21
+ * the debounce lands, and re-reading would fight the user's keystrokes.
22
+ *
23
+ * Rendered with `h()` (no JSX) against happy-dom, with a fake services seam —
24
+ * the kit is a published package and must not reach into the host's source.
25
+ */
26
+
27
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
28
+ import { Window } from 'happy-dom';
29
+ import { h, render } from 'preact';
30
+ import { act } from 'preact/test-utils';
31
+ import { configureUiKit, useSessionValue, type PanelContext, type UiKitServices } from '@ompchamber/ui';
32
+
33
+ const DOM_GLOBALS = ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'HTMLInputElement', 'Event', 'MouseEvent', 'KeyboardEvent'] as const;
34
+ const nativeGlobals: Partial<Record<(typeof DOM_GLOBALS)[number], unknown>> = {};
35
+
36
+ const CONTEXT: PanelContext = { sessionId: 's1', workspacePath: '/ws', theme: 'paper' };
37
+
38
+ /** The store behind the fake seam: one slot per session+key, as the host has. */
39
+ function makeServices() {
40
+ const store = new Map<string, string>();
41
+ const listeners = new Map<string, Set<() => void>>();
42
+ const slotKey = (key: string) => `${CONTEXT.sessionId}:${key}`;
43
+
44
+ const services: UiKitServices = {
45
+ context: () => CONTEXT,
46
+ subscribe: () => () => {},
47
+ getSessionValue: (_sessionId, key) => store.get(slotKey(key)) ?? null,
48
+ setSessionValue: (_sessionId, key, value) => {
49
+ store.set(slotKey(key), value);
50
+ for (const listener of [...(listeners.get(slotKey(key)) ?? [])]) listener();
51
+ },
52
+ subscribeSessionValue: (_sessionId, key, listener) => {
53
+ const bucket = listeners.get(slotKey(key)) ?? new Set();
54
+ bucket.add(listener);
55
+ listeners.set(slotKey(key), bucket);
56
+ return () => bucket.delete(listener);
57
+ },
58
+ readWorkspaceFile: async () => '',
59
+ };
60
+
61
+ /** What a DIFFERENT component's write looks like, reaching every reader. */
62
+ const externalWrite = (key: string, value: string) => services.setSessionValue(CONTEXT.sessionId, key, value);
63
+
64
+ return { services, store, externalWrite };
65
+ }
66
+
67
+ let container: HTMLElement;
68
+
69
+ beforeEach(() => {
70
+ const win = new Window({ url: 'http://localhost' });
71
+ const target = globalThis as unknown as Record<string, unknown>;
72
+ for (const key of DOM_GLOBALS) {
73
+ if (!(key in nativeGlobals)) nativeGlobals[key] = target[key];
74
+ target[key] = (win as unknown as Record<string, unknown>)[key];
75
+ }
76
+ container = document.createElement('div') as unknown as HTMLElement;
77
+ document.body.appendChild(container as never);
78
+ });
79
+
80
+ afterEach(() => {
81
+ render(null, container);
82
+ container.remove();
83
+ const target = globalThis as unknown as Record<string, unknown>;
84
+ for (const key of DOM_GLOBALS) {
85
+ if (key in nativeGlobals) target[key] = nativeGlobals[key];
86
+ else delete target[key];
87
+ }
88
+ });
89
+
90
+ describe('useSessionValue', () => {
91
+ test('a readout follows a write made by ANOTHER component', async () => {
92
+ const { services, externalWrite } = makeServices();
93
+ configureUiKit(services);
94
+
95
+ /** Reads the slot without ever writing it — the readout's shape. */
96
+ function Readout() {
97
+ const { value } = useSessionValue('note', 10);
98
+ return h('span', { id: 'readout' }, String((value ?? '').length));
99
+ }
100
+
101
+ await act(async () => {
102
+ render(h(Readout, {}), container);
103
+ });
104
+ expect(container.textContent).toBe('0');
105
+
106
+ // The field component writes; the readout must catch up.
107
+ await act(async () => {
108
+ externalWrite('note', 'hello');
109
+ });
110
+ expect(container.textContent).toBe('5');
111
+ });
112
+
113
+ test('a component mid-edit keeps its own text until the debounce lands', async () => {
114
+ const { services } = makeServices();
115
+ configureUiKit(services);
116
+
117
+ function Field() {
118
+ const { value, update } = useSessionValue('note', 10_000);
119
+ return h('input', { id: 'field', value: value ?? '', onInput: (event: Event) => update((event.target as HTMLInputElement).value) });
120
+ }
121
+
122
+ await act(async () => {
123
+ render(h(Field, {}), container);
124
+ });
125
+
126
+ const field = container.querySelector('#field') as HTMLInputElement;
127
+ await act(async () => {
128
+ field.value = 'typed';
129
+ field.dispatchEvent(new Event('input', { bubbles: true }));
130
+ });
131
+
132
+ // A long debounce: the store still holds nothing, and the field must NOT
133
+ // re-read it — that would erase the keystroke on screen.
134
+ expect(field.value).toBe('typed');
135
+ });
136
+
137
+ test('a write from another component does not mark this field dirty', async () => {
138
+ const { services, externalWrite, store } = makeServices();
139
+ configureUiKit(services);
140
+
141
+ /** A field that would write its value back if an outside write set `saving`. */
142
+ function Field() {
143
+ const { value, update } = useSessionValue('note', 10);
144
+ return h('input', { id: 'field', value: value ?? '', onInput: (event: Event) => update((event.target as HTMLInputElement).value) });
145
+ }
146
+
147
+ await act(async () => {
148
+ render(h(Field, {}), container);
149
+ });
150
+
151
+ await act(async () => {
152
+ externalWrite('note', 'from elsewhere');
153
+ });
154
+
155
+ // The field shows the outside value and has not overwritten it.
156
+ expect((container.querySelector('#field') as HTMLInputElement).value).toBe('from elsewhere');
157
+ const settled = Promise.withResolvers<void>();
158
+ setTimeout(() => settled.resolve(), 40);
159
+ await settled.promise;
160
+ expect(store.get(`${CONTEXT.sessionId}:note`)).toBe('from elsewhere');
161
+ });
162
+ });