@lmzhen/dsh-evolution-settings-ui 0.17.0-rc.1 → 0.17.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/README.md +10 -0
- package/lib/client.js +117 -16
- package/lib/types/client/messages.d.ts +1 -0
- package/lib/types/client/seam.d.ts +1 -1
- package/lib/types/client/source.d.ts +54 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -54,6 +54,16 @@ A card's inject face carries `hooks: { paramSection: source }` — that is the S
|
|
|
54
54
|
|
|
55
55
|
The props type is spelled the way the renderer derives it — `Omit<ParamCardFace, 'hooks'> & { useParamSection }` — so re-introducing that mistake fails `tsc` instead of failing the operator's save. 0.7.0 shipped the mistake; 0.7.1 fixed it and derived the type this way.
|
|
56
56
|
|
|
57
|
+
## The seat contract: lazy probe, stable snapshot, one source object
|
|
58
|
+
|
|
59
|
+
`src/client/source.ts` is the one place that turns `configForms.get(namespace)` into the three things a card needs — a snapshot, a subscription and the two write calls — and it holds three obligations the platform's renderer imposes:
|
|
60
|
+
|
|
61
|
+
- **The seat is probed lazily.** `apply()` may run before the settings shell that declares this section and provides `configForms`, so a seat captured at apply time would pin the row to the empty state for the life of its fiber. The source reads a `SeatProbe` on every snapshot read; the write face re-probes per call.
|
|
62
|
+
- **The projected snapshot keeps ONE reference until the row's raw snapshot moves.** The renderer binds the `hooks` compartment through `useSyncExternalStoreWithSelector`, which compares by reference: a projection that builds an object per call never compares equal, so React aborts the card (minified invariant #185) and the slot error boundary replaces it with an empty placeholder — that is 0.17.0's "all the cards disappeared". The projection caches per raw snapshot reference, and a projection failure returns the frozen `PROJECTION_FAILED` snapshot instead of throwing: a throw from `getSnapshot()` happens inside the renderer's render pass, where this bundle cannot catch it and where a `try`/`catch` around a hook call would not be legal React.
|
|
63
|
+
- **One source object per namespace.** The renderer caches a hook binding per SOURCE object, so a source built per render re-binds the hook and remounts the card. `createSourceCache(probe)` owns the per-namespace cache and goes away with the fiber.
|
|
64
|
+
|
|
65
|
+
A card therefore tells three unavailable reasons apart — `seat-missing` (no settings surface composed), `projection-failed` (the seat served the row and reading it failed; the error is logged once) and `not-served` (the row itself is unavailable) — instead of painting one blank state. `N29`/`N30` in the family guard hold the shape of this seam, and `tests/section.client.spec.ts` pins the stable reference, the re-projection on change, the frozen failure value, the per-namespace identity and the late seat.
|
|
66
|
+
|
|
57
67
|
## Styling
|
|
58
68
|
|
|
59
69
|
The card draws from the FAMILY's scale, not from magic numbers: `src/client/tokens.ts` is generated from `packages/scripts/client-tokens.json` (identical bytes in the history panel, which shares it), and its type steps are relative to the surface that hosts the card — `calc(1em - 2px)` for a hint, `calc(1em + 2px)` for the title — so the card no longer resizes itself with the `--dsh-content-font-size*` setting, which is documented as affecting conversation content only and which the platform's own surfaces never read. Fields take the family's single focus ring (`outline: var(--evo-focus-ring)`), the same one the history panel draws, instead of a recoloured border. The design system's CSS is not exported to packages outside the platform repository, so this bundle carries its own stylesheet and injects it once behind a `<style data-plugin-css="…">` tag — the mechanism the platform's own client bundles use. Every rule reads a `--dsw-alias-*` design token, so light and dark follow the theme without a colour of our own, and the field metrics (12px padding, 6px gap, 13px label, 12px hint) copy the platform's settings fields.
|
package/lib/client.js
CHANGED
|
@@ -806,6 +806,7 @@ window.__ModuleLoader__.load({
|
|
|
806
806
|
loading: "正在读取设置…",
|
|
807
807
|
unavailable: "这个功能没有启动(对应的插件没有安装)",
|
|
808
808
|
seatMissing: "这个界面没有提供设置服务,参数只能看不能改",
|
|
809
|
+
projectionFailed: "这一块读设置时出错,已记入日志;其它参数不受影响",
|
|
809
810
|
readonly: "本次会话不允许写入设置(临时运行模式)",
|
|
810
811
|
apply: "保存",
|
|
811
812
|
reset: "恢复默认值",
|
|
@@ -852,6 +853,7 @@ window.__ModuleLoader__.load({
|
|
|
852
853
|
loading: "reading settings…",
|
|
853
854
|
unavailable: "this feature is not running (its plugin is not installed)",
|
|
854
855
|
seatMissing: "this shell serves no settings surface, so the parameters cannot be edited here",
|
|
856
|
+
projectionFailed: "reading this row failed and the error is in the log; the other parameters are unaffected",
|
|
855
857
|
readonly: "settings cannot be written in this session (temporary mode)",
|
|
856
858
|
apply: "Save",
|
|
857
859
|
reset: "Reset to default",
|
|
@@ -1049,7 +1051,7 @@ window.__ModuleLoader__.load({
|
|
|
1049
1051
|
const [error, setError] = (0, react.useState)("");
|
|
1050
1052
|
const [pending, setPending] = (0, react.useState)(null);
|
|
1051
1053
|
if (snapshot.status === "loading") return (0, react.createElement)("p", { className: "evolution-param-note" }, t("loading"));
|
|
1052
|
-
if (snapshot.status === "unavailable") return (0, react.createElement)("p", { className: "evolution-param-note" }, t(snapshot.reason === "seat-missing" ? "seatMissing" : "unavailable"));
|
|
1054
|
+
if (snapshot.status === "unavailable") return (0, react.createElement)("p", { className: "evolution-param-note" }, t(snapshot.reason === "seat-missing" ? "seatMissing" : snapshot.reason === "projection-failed" ? "projectionFailed" : "unavailable"));
|
|
1053
1055
|
const user = isSection$1(snapshot.user) ? snapshot.user : {};
|
|
1054
1056
|
const value = isSection$1(snapshot.value) ? snapshot.value : {};
|
|
1055
1057
|
const disabled = !snapshot.writable;
|
|
@@ -1164,6 +1166,16 @@ window.__ModuleLoader__.load({
|
|
|
1164
1166
|
writable: false,
|
|
1165
1167
|
reason: "seat-missing"
|
|
1166
1168
|
});
|
|
1169
|
+
/** What a card shows when the seat served the row but projecting it failed. A frozen constant
|
|
1170
|
+
* so a failing row keeps ONE reference: a fresh object per call would reintroduce the
|
|
1171
|
+
* render loop this module exists to prevent. */
|
|
1172
|
+
const PROJECTION_FAILED = Object.freeze({
|
|
1173
|
+
status: "unavailable",
|
|
1174
|
+
value: void 0,
|
|
1175
|
+
user: void 0,
|
|
1176
|
+
writable: false,
|
|
1177
|
+
reason: "projection-failed"
|
|
1178
|
+
});
|
|
1167
1179
|
/**
|
|
1168
1180
|
* Whether a value is a plain section object.
|
|
1169
1181
|
* @param value - the value to test.
|
|
@@ -1187,30 +1199,91 @@ window.__ModuleLoader__.load({
|
|
|
1187
1199
|
};
|
|
1188
1200
|
}
|
|
1189
1201
|
/**
|
|
1202
|
+
* Bind one row's form to a projection that is stable per raw snapshot and never throws.
|
|
1203
|
+
* @param form - the row's client form, as the settings seat hands it over.
|
|
1204
|
+
* @returns the projection the card's hook reads.
|
|
1205
|
+
*/
|
|
1206
|
+
function stableProjection(form) {
|
|
1207
|
+
let lastRaw;
|
|
1208
|
+
let lastOut;
|
|
1209
|
+
let reported = false;
|
|
1210
|
+
const failed = (error) => {
|
|
1211
|
+
if (!reported) {
|
|
1212
|
+
reported = true;
|
|
1213
|
+
console.error("[evolution-settings] projecting one row failed:", error);
|
|
1214
|
+
}
|
|
1215
|
+
return PROJECTION_FAILED;
|
|
1216
|
+
};
|
|
1217
|
+
return () => {
|
|
1218
|
+
let raw;
|
|
1219
|
+
try {
|
|
1220
|
+
raw = form.getSnapshot();
|
|
1221
|
+
} catch (error) {
|
|
1222
|
+
return failed(error);
|
|
1223
|
+
}
|
|
1224
|
+
if (lastOut !== void 0 && raw === lastRaw) return lastOut;
|
|
1225
|
+
try {
|
|
1226
|
+
const next = sectionSnapshotOf(raw);
|
|
1227
|
+
lastRaw = raw;
|
|
1228
|
+
lastOut = next;
|
|
1229
|
+
return next;
|
|
1230
|
+
} catch (error) {
|
|
1231
|
+
return failed(error);
|
|
1232
|
+
}
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
/**
|
|
1190
1236
|
* The live source one row's card binds.
|
|
1191
|
-
* @param
|
|
1237
|
+
* @param probe - the lazily probed settings seat.
|
|
1192
1238
|
* @param namespace - the row's entry id.
|
|
1193
|
-
* @returns the source; without a seat it reports the stated empty state and
|
|
1239
|
+
* @returns the source; without a seat it reports the stated empty state, and it re-probes on
|
|
1240
|
+
* every read so a seat that appears later is picked up without a timer.
|
|
1194
1241
|
*/
|
|
1195
|
-
function sourceFor(
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1242
|
+
function sourceFor(probe, namespace) {
|
|
1243
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
1244
|
+
let form;
|
|
1245
|
+
let projected;
|
|
1246
|
+
let offForm;
|
|
1247
|
+
const bind = () => {
|
|
1248
|
+
if (form !== void 0) return;
|
|
1249
|
+
const seat = probe();
|
|
1250
|
+
if (seat === void 0) return;
|
|
1251
|
+
form = seat.get(namespace);
|
|
1252
|
+
projected = stableProjection(form);
|
|
1253
|
+
};
|
|
1254
|
+
const attach = () => {
|
|
1255
|
+
if (form === void 0 || offForm !== void 0) return;
|
|
1256
|
+
offForm = form.subscribe(() => {
|
|
1257
|
+
for (const listener of listeners) listener();
|
|
1258
|
+
});
|
|
1199
1259
|
};
|
|
1200
|
-
const form = seat.get(namespace);
|
|
1201
1260
|
return {
|
|
1202
|
-
getSnapshot: () =>
|
|
1203
|
-
|
|
1261
|
+
getSnapshot: () => {
|
|
1262
|
+
bind();
|
|
1263
|
+
return projected === void 0 ? SEAT_MISSING : projected();
|
|
1264
|
+
},
|
|
1265
|
+
subscribe: (listener) => {
|
|
1266
|
+
listeners.add(listener);
|
|
1267
|
+
bind();
|
|
1268
|
+
attach();
|
|
1269
|
+
return () => {
|
|
1270
|
+
listeners.delete(listener);
|
|
1271
|
+
if (listeners.size === 0) {
|
|
1272
|
+
offForm?.();
|
|
1273
|
+
offForm = void 0;
|
|
1274
|
+
}
|
|
1275
|
+
};
|
|
1276
|
+
}
|
|
1204
1277
|
};
|
|
1205
1278
|
}
|
|
1206
1279
|
/**
|
|
1207
1280
|
* The write pair for one row.
|
|
1208
|
-
* @param
|
|
1281
|
+
* @param probe - the lazily probed settings seat.
|
|
1209
1282
|
* @param namespace - the row's entry id.
|
|
1210
1283
|
* @returns the two calls a card's save/reset path uses; without a seat they resolve without writing.
|
|
1211
1284
|
*/
|
|
1212
|
-
function writeFaceFor(
|
|
1213
|
-
const form = () =>
|
|
1285
|
+
function writeFaceFor(probe, namespace) {
|
|
1286
|
+
const form = () => probe()?.get(namespace);
|
|
1214
1287
|
return {
|
|
1215
1288
|
write: async (field, value) => {
|
|
1216
1289
|
await form()?.set(field, value);
|
|
@@ -1220,6 +1293,30 @@ window.__ModuleLoader__.load({
|
|
|
1220
1293
|
}
|
|
1221
1294
|
};
|
|
1222
1295
|
}
|
|
1296
|
+
/**
|
|
1297
|
+
* One source object per namespace, for the life of the plugin fiber.
|
|
1298
|
+
*
|
|
1299
|
+
* The renderer caches a hook binding per SOURCE OBJECT, so building a source per render
|
|
1300
|
+
* would re-bind the hook and remount the card. The cache is owned by the caller's fiber and
|
|
1301
|
+
* cleared on dispose.
|
|
1302
|
+
* @param probe - the lazily probed settings seat.
|
|
1303
|
+
* @returns the per-namespace source cache and its disposer.
|
|
1304
|
+
*/
|
|
1305
|
+
function createSourceCache(probe) {
|
|
1306
|
+
const sources = /* @__PURE__ */ new Map();
|
|
1307
|
+
return {
|
|
1308
|
+
sourceFor: (namespace) => {
|
|
1309
|
+
const existing = sources.get(namespace);
|
|
1310
|
+
if (existing !== void 0) return existing;
|
|
1311
|
+
const source = sourceFor(probe, namespace);
|
|
1312
|
+
sources.set(namespace, source);
|
|
1313
|
+
return source;
|
|
1314
|
+
},
|
|
1315
|
+
dispose: () => {
|
|
1316
|
+
sources.clear();
|
|
1317
|
+
}
|
|
1318
|
+
};
|
|
1319
|
+
}
|
|
1223
1320
|
//#endregion
|
|
1224
1321
|
//#region src/client/RowConfigCard.ts
|
|
1225
1322
|
/**
|
|
@@ -1462,7 +1559,11 @@ window.__ModuleLoader__.load({
|
|
|
1462
1559
|
}
|
|
1463
1560
|
return bound(key);
|
|
1464
1561
|
};
|
|
1465
|
-
const
|
|
1562
|
+
const probe = () => ctx.get("configForms");
|
|
1563
|
+
const sources = createSourceCache(probe);
|
|
1564
|
+
ctx.effect(() => () => {
|
|
1565
|
+
sources.dispose();
|
|
1566
|
+
}, "evolution-settings: sources");
|
|
1466
1567
|
seam.slots.inject("settings.section", function* () {
|
|
1467
1568
|
yield seam.slots.register({
|
|
1468
1569
|
name: "settings.section",
|
|
@@ -1478,8 +1579,8 @@ window.__ModuleLoader__.load({
|
|
|
1478
1579
|
});
|
|
1479
1580
|
seam.slots.inject(CARD_SLOT, function* () {
|
|
1480
1581
|
for (const section of CLIENT_PARAM_SECTIONS) {
|
|
1481
|
-
const source = sourceFor(
|
|
1482
|
-
const { write, clear } = writeFaceFor(
|
|
1582
|
+
const source = sources.sourceFor(section.namespace);
|
|
1583
|
+
const { write, clear } = writeFaceFor(probe, section.namespace);
|
|
1483
1584
|
const face = () => ({
|
|
1484
1585
|
namespace: section.namespace,
|
|
1485
1586
|
fields: section.fields,
|
|
@@ -19,6 +19,7 @@ export declare const zh: {
|
|
|
19
19
|
readonly loading: "正在读取设置…";
|
|
20
20
|
readonly unavailable: "这个功能没有启动(对应的插件没有安装)";
|
|
21
21
|
readonly seatMissing: "这个界面没有提供设置服务,参数只能看不能改";
|
|
22
|
+
readonly projectionFailed: "这一块读设置时出错,已记入日志;其它参数不受影响";
|
|
22
23
|
readonly readonly: "本次会话不允许写入设置(临时运行模式)";
|
|
23
24
|
readonly apply: "保存";
|
|
24
25
|
readonly reset: "恢复默认值";
|
|
@@ -47,7 +47,7 @@ export interface ParamSectionSnapshot {
|
|
|
47
47
|
* all, or the seat is there and serves no such row. The two read differently to the
|
|
48
48
|
* operator (one is the UI's own gap, the other is a row that is not composed).
|
|
49
49
|
*/
|
|
50
|
-
reason?: 'seat-missing' | 'not-served';
|
|
50
|
+
reason?: 'seat-missing' | 'not-served' | 'projection-failed';
|
|
51
51
|
}
|
|
52
52
|
/** The reactive source the renderer binds to a `use<Name>` seat. */
|
|
53
53
|
export interface ParamSectionSource {
|
|
@@ -4,11 +4,31 @@
|
|
|
4
4
|
* One place maps `configForms.get(entryId)` to the three things a card needs — a
|
|
5
5
|
* snapshot, a subscription and the two write calls — so our own section's cards and the
|
|
6
6
|
* Plugins page's per-row entry cannot drift apart in how they read or write a row.
|
|
7
|
+
*
|
|
8
|
+
* Two invariants live here, both required by the renderer's hook seam:
|
|
9
|
+
*
|
|
10
|
+
* - **Stable references.** The renderer binds this source through
|
|
11
|
+
* `useSyncExternalStoreWithSelector`, which compares the projected snapshot by reference.
|
|
12
|
+
* A `getSnapshot()` that builds a new object on every call never compares equal, so the
|
|
13
|
+
* card re-renders until React aborts it (minified invariant #185) and the slot error
|
|
14
|
+
* boundary replaces the card with an empty placeholder. The projection therefore caches
|
|
15
|
+
* per raw snapshot reference.
|
|
16
|
+
* - **Never throws.** A throw from `getSnapshot()` happens inside the renderer's render pass,
|
|
17
|
+
* where this bundle cannot catch it and a card-level `try`/`catch` around a hook call is
|
|
18
|
+
* not legal React. Projection failures return a frozen unavailable snapshot instead.
|
|
19
|
+
*
|
|
20
|
+
* The seat itself is probed LAZILY: `apply()` runs before the settings shell may be active
|
|
21
|
+
* (the shell declares `settings.section` and provides `configForms`), so reading the seat at
|
|
22
|
+
* apply time would capture `undefined` for the whole life of the fiber.
|
|
7
23
|
* @module @lmzhen/dsh-evolution-settings-ui/client
|
|
8
24
|
*/
|
|
9
|
-
import type { ConfigFormSnapshotLike, ConfigFormsSeat, ParamSectionSnapshot, ParamSectionSource } from './seam.ts';
|
|
25
|
+
import type { ConfigFormLike, ConfigFormSnapshotLike, ConfigFormsSeat, ParamSectionSnapshot, ParamSectionSource } from './seam.ts';
|
|
10
26
|
/** What a card shows when this UI found no settings seat at all. */
|
|
11
27
|
export declare const SEAT_MISSING: ParamSectionSnapshot;
|
|
28
|
+
/** What a card shows when the seat served the row but projecting it failed. A frozen constant
|
|
29
|
+
* so a failing row keeps ONE reference: a fresh object per call would reintroduce the
|
|
30
|
+
* render loop this module exists to prevent. */
|
|
31
|
+
export declare const PROJECTION_FAILED: ParamSectionSnapshot;
|
|
12
32
|
/**
|
|
13
33
|
* Whether a value is a plain section object.
|
|
14
34
|
* @param value - the value to test.
|
|
@@ -21,21 +41,49 @@ export declare function isSection(value: unknown): value is Record<string, unkno
|
|
|
21
41
|
* @returns the card's snapshot, with the reason an unavailable one is unavailable.
|
|
22
42
|
*/
|
|
23
43
|
export declare function sectionSnapshotOf(snapshot: ConfigFormSnapshotLike): ParamSectionSnapshot;
|
|
44
|
+
/**
|
|
45
|
+
* Bind one row's form to a projection that is stable per raw snapshot and never throws.
|
|
46
|
+
* @param form - the row's client form, as the settings seat hands it over.
|
|
47
|
+
* @returns the projection the card's hook reads.
|
|
48
|
+
*/
|
|
49
|
+
export declare function stableProjection(form: ConfigFormLike): () => ParamSectionSnapshot;
|
|
50
|
+
/**
|
|
51
|
+
* The lazily probed client settings seat.
|
|
52
|
+
*
|
|
53
|
+
* Probed at read time rather than captured at apply time: this row may activate before the
|
|
54
|
+
* settings shell, and a captured `undefined` would pin every card to the empty state.
|
|
55
|
+
* @returns the seat, or undefined while no settings surface is composed.
|
|
56
|
+
*/
|
|
57
|
+
export type SeatProbe = () => ConfigFormsSeat | undefined;
|
|
24
58
|
/**
|
|
25
59
|
* The live source one row's card binds.
|
|
26
|
-
* @param
|
|
60
|
+
* @param probe - the lazily probed settings seat.
|
|
27
61
|
* @param namespace - the row's entry id.
|
|
28
|
-
* @returns the source; without a seat it reports the stated empty state and
|
|
62
|
+
* @returns the source; without a seat it reports the stated empty state, and it re-probes on
|
|
63
|
+
* every read so a seat that appears later is picked up without a timer.
|
|
29
64
|
*/
|
|
30
|
-
export declare function sourceFor(
|
|
65
|
+
export declare function sourceFor(probe: SeatProbe, namespace: string): ParamSectionSource;
|
|
31
66
|
/**
|
|
32
67
|
* The write pair for one row.
|
|
33
|
-
* @param
|
|
68
|
+
* @param probe - the lazily probed settings seat.
|
|
34
69
|
* @param namespace - the row's entry id.
|
|
35
70
|
* @returns the two calls a card's save/reset path uses; without a seat they resolve without writing.
|
|
36
71
|
*/
|
|
37
|
-
export declare function writeFaceFor(
|
|
72
|
+
export declare function writeFaceFor(probe: SeatProbe, namespace: string): {
|
|
38
73
|
write: (field: string, value: unknown) => Promise<void>;
|
|
39
74
|
clear: (field: string) => Promise<void>;
|
|
40
75
|
};
|
|
76
|
+
/**
|
|
77
|
+
* One source object per namespace, for the life of the plugin fiber.
|
|
78
|
+
*
|
|
79
|
+
* The renderer caches a hook binding per SOURCE OBJECT, so building a source per render
|
|
80
|
+
* would re-bind the hook and remount the card. The cache is owned by the caller's fiber and
|
|
81
|
+
* cleared on dispose.
|
|
82
|
+
* @param probe - the lazily probed settings seat.
|
|
83
|
+
* @returns the per-namespace source cache and its disposer.
|
|
84
|
+
*/
|
|
85
|
+
export declare function createSourceCache(probe: SeatProbe): {
|
|
86
|
+
sourceFor: (namespace: string) => ParamSectionSource;
|
|
87
|
+
dispose: () => void;
|
|
88
|
+
};
|
|
41
89
|
//# sourceMappingURL=source.d.ts.map
|
package/package.json
CHANGED