@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 +2 -2
- package/src/index.tsx +47 -5
- package/src/session-value.test.tsx +162 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ompchamber/ui",
|
|
3
|
-
"version": "2.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": "
|
|
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
|
-
|
|
114
|
-
setStatus('loading');
|
|
132
|
+
const read = useCallback((): string => {
|
|
115
133
|
try {
|
|
116
|
-
|
|
117
|
-
|
|
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
|
+
});
|