@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 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 seat - the probed settings seat, when the deployment composed it.
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 never notifies.
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(seat, namespace) {
1196
- if (seat === void 0) return {
1197
- getSnapshot: () => SEAT_MISSING,
1198
- subscribe: () => () => {}
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: () => sectionSnapshotOf(form.getSnapshot()),
1203
- subscribe: (listener) => form.subscribe(listener)
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 seat - the probed settings seat, when the deployment composed it.
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(seat, namespace) {
1213
- const form = () => seat?.get(namespace);
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 configForms = ctx.get("configForms");
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(configForms, section.namespace);
1482
- const { write, clear } = writeFaceFor(configForms, section.namespace);
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 seat - the probed settings seat, when the deployment composed it.
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 never notifies.
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(seat: ConfigFormsSeat | undefined, namespace: string): ParamSectionSource;
65
+ export declare function sourceFor(probe: SeatProbe, namespace: string): ParamSectionSource;
31
66
  /**
32
67
  * The write pair for one row.
33
- * @param seat - the probed settings seat, when the deployment composed it.
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(seat: ConfigFormsSeat | undefined, namespace: string): {
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lmzhen/dsh-evolution-settings-ui",
3
3
  "description": "Web settings cards for the evolution family's parameter namespaces (community build)",
4
- "version": "0.17.0-rc.1",
4
+ "version": "0.17.1",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },