homebridge-plugin-utils 2.0.0 → 2.2.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.
- package/build/tsconfig.json +15 -0
- package/dist/backpressure.d.ts +1 -1
- package/dist/backpressure.js +4 -4
- package/dist/backpressure.js.map +1 -1
- package/dist/cli/index.d.ts +93 -5
- package/dist/cli/index.js +296 -30
- package/dist/cli/index.js.map +1 -1
- package/dist/clock-double.js +2 -2
- package/dist/clock-double.js.map +1 -1
- package/dist/disposable-stack.d.ts +59 -0
- package/dist/disposable-stack.js +155 -0
- package/dist/disposable-stack.js.map +1 -0
- package/dist/docChrome.d.ts +263 -0
- package/dist/docChrome.js +364 -0
- package/dist/docChrome.js.map +1 -0
- package/dist/featureOptions-docs.d.ts +2 -2
- package/dist/featureOptions-docs.js +1 -1
- package/dist/featureOptions.d.ts +5 -5
- package/dist/featureOptions.js +6 -6
- package/dist/ffmpeg/exec.d.ts +2 -2
- package/dist/ffmpeg/exec.js +2 -2
- package/dist/ffmpeg/fmp4-builders.d.ts +77 -0
- package/dist/ffmpeg/fmp4-builders.js +163 -0
- package/dist/ffmpeg/fmp4-builders.js.map +1 -0
- package/dist/ffmpeg/index.d.ts +1 -0
- package/dist/ffmpeg/index.js +1 -0
- package/dist/ffmpeg/index.js.map +1 -1
- package/dist/ffmpeg/mp4-assembler.d.ts +45 -4
- package/dist/ffmpeg/mp4-assembler.js +45 -8
- package/dist/ffmpeg/mp4-assembler.js.map +1 -1
- package/dist/ffmpeg/mp4-parser.d.ts +1 -1
- package/dist/ffmpeg/mp4-parser.js +1 -1
- package/dist/ffmpeg/options.d.ts +17 -0
- package/dist/ffmpeg/options.js +50 -25
- package/dist/ffmpeg/options.js.map +1 -1
- package/dist/ffmpeg/process.d.ts +1 -1
- package/dist/ffmpeg/process.js +4 -4
- package/dist/ffmpeg/process.js.map +1 -1
- package/dist/ffmpeg/record.d.ts +26 -1
- package/dist/ffmpeg/record.js +12 -0
- package/dist/ffmpeg/record.js.map +1 -1
- package/dist/ffmpeg/recording-process-double.d.ts +13 -0
- package/dist/ffmpeg/recording-process-double.js +22 -0
- package/dist/ffmpeg/recording-process-double.js.map +1 -1
- package/dist/ffmpeg/rtp-parser.d.ts +1 -1
- package/dist/ffmpeg/rtp-parser.js +1 -1
- package/dist/ffmpeg/rtp.d.ts +4 -4
- package/dist/ffmpeg/rtp.js +7 -6
- package/dist/ffmpeg/rtp.js.map +1 -1
- package/dist/ffmpeg/stream.js +1 -1
- package/dist/ffmpeg/stream.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/logclient/auth.d.ts +3 -3
- package/dist/logclient/auth.js +2 -2
- package/dist/logclient/auth.js.map +1 -1
- package/dist/logclient/cli-run.js +2 -2
- package/dist/logclient/cli-run.js.map +1 -1
- package/dist/logclient/cli.js +2 -2
- package/dist/logclient/client.js +2 -2
- package/dist/logclient/client.js.map +1 -1
- package/dist/logclient/rest.js +1 -1
- package/dist/logclient/socket-double.d.ts +1 -1
- package/dist/logclient/socket-double.js +1 -1
- package/dist/logclient/socket.js +5 -5
- package/dist/logclient/socket.js.map +1 -1
- package/dist/logclient/time-window.d.ts +1 -1
- package/dist/mqttClient.d.ts +1 -1
- package/dist/mqttClient.js +3 -3
- package/dist/mqttClient.js.map +1 -1
- package/dist/timer-registry.d.ts +100 -0
- package/dist/timer-registry.js +184 -0
- package/dist/timer-registry.js.map +1 -0
- package/dist/ui/featureOptions.js +6 -6
- package/dist/ui/pluginConfigSession.mjs +1 -1
- package/dist/ui/webUi-featureOptions/effects/persist.mjs +2 -2
- package/dist/ui/webUi-featureOptions/effects/theme.mjs +1 -1
- package/dist/ui/webUi-featureOptions/effects/tokens.mjs +1 -1
- package/dist/ui/webUi-featureOptions/rendering.mjs +2 -2
- package/dist/ui/webUi-featureOptions/selectors.mjs +3 -3
- package/dist/ui/webUi-featureOptions/state.mjs +72 -15
- package/dist/ui/webUi-featureOptions/store.mjs +1 -1
- package/dist/ui/webUi-featureOptions/utils.mjs +25 -1
- package/dist/ui/webUi-featureOptions/views/connectionError.mjs +28 -11
- package/dist/ui/webUi-featureOptions/views/deviceInfo.mjs +7 -0
- package/dist/ui/webUi-featureOptions/views/header.mjs +22 -5
- package/dist/ui/webUi-featureOptions/views/nav.mjs +39 -32
- package/dist/ui/webUi-featureOptions/views/options.mjs +13 -7
- package/dist/ui/webUi-featureOptions/views/search.mjs +2 -2
- package/dist/ui/webUi-featureOptions.mjs +109 -61
- package/dist/ui/webUi.mjs +48 -11
- package/dist/util.d.ts +118 -16
- package/dist/util.js +96 -14
- package/dist/util.js.map +1 -1
- package/dist/webui-loader.d.ts +80 -0
- package/dist/webui-loader.js +373 -0
- package/dist/webui-loader.js.map +1 -0
- package/package.json +10 -10
|
@@ -13,10 +13,10 @@ import { applyClearOption, applySetOption, buildCatalogIndex } from "../featureO
|
|
|
13
13
|
* and derives its view via selectors. Discriminated unions encode the variant types the UI moves through, each listed below:
|
|
14
14
|
*
|
|
15
15
|
* - {@link Scope} - `{kind: "global"}` | `{kind: "controller", controllerId}` | `{kind: "device", controllerId, deviceId}`. The selection pointer. Discriminated
|
|
16
|
-
* because each kind carries different data; merging them into a flat record would smear the
|
|
16
|
+
* because each kind carries different data; merging them into a flat record would smear the guarantees across two fields and force consumers to recover the
|
|
17
17
|
* kind via predicates.
|
|
18
18
|
* - {@link LifecycleStatus} - `loading` | `ready` | `persisting` | `persist-error` | `connection-error`. The page-state pointer. Discriminated because the
|
|
19
|
-
* variants carry different per-state payloads (a snapshot when persisting, an error when failed,
|
|
19
|
+
* variants carry different per-state payloads (a snapshot when persisting, an error when failed, the full display copy when the connection broke).
|
|
20
20
|
* - {@link Catalog} - `CatalogIndex` (from featureOptions.ts) extended with plugin-provided validator callbacks. Bundled as one value because both the index and
|
|
21
21
|
* the validators are plugin-provided immutable config moving together; splitting them would force every consumer that needs both to take two parameters.
|
|
22
22
|
*
|
|
@@ -25,7 +25,10 @@ import { applyClearOption, applySetOption, buildCatalogIndex } from "../featureO
|
|
|
25
25
|
*
|
|
26
26
|
* - `model:loaded` - first load: catalog, configuredOptions, controllers, mode are populated; status transitions to ready.
|
|
27
27
|
* - `controllers:loaded` - controllers-only refresh (reducer + nav subscriber wired) that no code currently dispatches; the retry path re-runs the full `model:loaded`.
|
|
28
|
-
* - `devices:
|
|
28
|
+
* - `devices:requested` - a device fetch is beginning: mints the next fetch sequence into state and records it as the pending request, so the outcome that
|
|
29
|
+
* eventually answers it can be told apart from a superseded one.
|
|
30
|
+
* - `devices:loaded` - a device fetch's outcome - its device list and connection error - stamped with the sequence its request minted. Applies only when it answers
|
|
31
|
+
* the pending request; a superseded or seq-less outcome is dropped at this chokepoint. A non-empty error also transitions status to connection-error.
|
|
29
32
|
* - `scope:changed` - selection pointer moved (global / controller / device).
|
|
30
33
|
* - `option:set` - single option enabled/disabled (with optional value) at some scope.
|
|
31
34
|
* - `option:cleared` - single option removed at some scope.
|
|
@@ -35,7 +38,7 @@ import { applyClearOption, applySetOption, buildCatalogIndex } from "../featureO
|
|
|
35
38
|
* - `persist:started` - persist call entering flight; status becomes persisting.
|
|
36
39
|
* - `persist:succeeded` - persist call landed on disk; anchor updated, status returns to ready.
|
|
37
40
|
* - `persist:failed` - final-attempt failure (no superseding mutation); configuredOptions rolls back to anchor, status becomes persist-error.
|
|
38
|
-
* - `connection:error` -
|
|
41
|
+
* - `connection:error` - the config re-sync failed before the page could render; status becomes connection-error carrying the full display copy the view renders.
|
|
39
42
|
*
|
|
40
43
|
* The reducer is pure: `(state, action) => state`. Unchanged slices retain their reference across dispatches (structural sharing), so memoized selectors that
|
|
41
44
|
* depend on a slice return cached results until that specific slice changes. Unknown action types throw - silently ignoring them would let typo bugs escape into
|
|
@@ -63,7 +66,7 @@ import { applyClearOption, applySetOption, buildCatalogIndex } from "../featureO
|
|
|
63
66
|
|
|
64
67
|
/**
|
|
65
68
|
* @typedef {Object} Validators
|
|
66
|
-
* @property {(device: Device) => boolean} isController - Predicate for "is this device a controller-as-device" (drives controller-vs-device scope
|
|
69
|
+
* @property {(device: Device) => boolean} isController - Predicate for "is this device a controller-as-device" (drives controller-vs-device scope distinction).
|
|
67
70
|
* @property {(device: Device | undefined, option: Object) => boolean} validOption - Predicate for "should this option render for this device."
|
|
68
71
|
* @property {(device: Device | undefined, category: Object) => boolean} validOptionCategory - Predicate for "should this category render for this device."
|
|
69
72
|
*/
|
|
@@ -100,8 +103,12 @@ import { applyClearOption, applySetOption, buildCatalogIndex } from "../featureO
|
|
|
100
103
|
* LifecycleStatus - The page-state pointer. Discriminated because the variants carry different per-state payloads. Drop a status variant when it stops being a
|
|
101
104
|
* named UI state; add one when a new named state surfaces.
|
|
102
105
|
*
|
|
106
|
+
* The `connection-error` variant carries its full display copy - `headline`, `guidance`, and `message` - so the connection-error view maps three text slots without
|
|
107
|
+
* hardcoding any prose. The two suppliers (the reducer's fetch-failure transition on {@link devices:loaded} and the orchestrator's config-sync-failure
|
|
108
|
+
* {@link connection:error} dispatch) each carry copy appropriate to their failure.
|
|
109
|
+
*
|
|
103
110
|
* @typedef {{kind: "loading"} | {kind: "ready"} | {kind: "persisting", snapshot: readonly string[]} | {kind: "persist-error", error: Error}
|
|
104
|
-
* | {kind: "connection-error", message: string}} LifecycleStatus
|
|
111
|
+
* | {kind: "connection-error", guidance: string, headline: string, message: string}} LifecycleStatus
|
|
105
112
|
*/
|
|
106
113
|
|
|
107
114
|
/**
|
|
@@ -113,10 +120,17 @@ import { applyClearOption, applySetOption, buildCatalogIndex } from "../featureO
|
|
|
113
120
|
* @property {readonly string[]} configuredOptions - The canonical user-state array. Mutations replace it via the pure transforms from featureOptions.ts.
|
|
114
121
|
* @property {readonly Controller[]} controllers - Controllers list (empty in device-only mode or before resolution).
|
|
115
122
|
* @property {readonly Device[]} devices - Devices list for the active controller (or the cached-accessories list in device-only mode).
|
|
123
|
+
* @property {number} devicesAppliedSeq - The sequence of the device-fetch outcome currently applied. The reducer's own verdict fact: a dispatcher reads it back to
|
|
124
|
+
* learn whether its outcome (the one carrying this sequence) is the one that landed, gating its follow-up work on one integer comparison rather than on the
|
|
125
|
+
* device array's reference, which the shared-empty-array idiom and a caching `getDevices` can alias across fetches.
|
|
116
126
|
* @property {string | null} devicesControllerId - Serial of the controller whose `devices` are loaded, or null (device-only / none yet). Preserves the
|
|
117
127
|
* device-to-controller association that `scope` drops once the selection goes global, so a loaded device's parent controller stays resolvable after the
|
|
118
128
|
* selection leaves controller scope.
|
|
119
|
-
* @property {{
|
|
129
|
+
* @property {{controllerId: string | null, seq: number} | null} devicesRequest - The pending device-fetch record, or null when no fetch is outstanding. A
|
|
130
|
+
* `devices:requested` records the latest fetch here; the `devices:loaded` that carries the same sequence clears it, and any other outcome is dropped.
|
|
131
|
+
* @property {number} devicesRequestSeq - The persistent monotonic fetch counter. Never reset within a store's life, so every fetch across the session gets a unique,
|
|
132
|
+
* increasing sequence and last-request-wins holds even for two fetches against the same controller.
|
|
133
|
+
* @property {{mode: "all" | "modified", query: string}} filter - Search and filter state. Two-field record because the dimensions are independent (every combination
|
|
120
134
|
* is valid and meaningful).
|
|
121
135
|
* @property {readonly string[]} initialOptions - The at-show() snapshot for "Revert to Saved." Stable across the session except when re-show() loads an option
|
|
122
136
|
* set that is not set-equal to the prior snapshot, in which case the snapshot is replaced.
|
|
@@ -143,6 +157,16 @@ const EMPTY_CATALOG = {
|
|
|
143
157
|
}
|
|
144
158
|
};
|
|
145
159
|
|
|
160
|
+
// The connection-error display copy for a controller fetch failure. The reducer supplies it at its one fetch-failure transition (on devices:loaded), so this
|
|
161
|
+
// controller wording lives here rather than being hardcoded in the connection-error view, which maps every text slot from the status. The per-fetch failure
|
|
162
|
+
// message travels back on the outcome and is layered on as `message`.
|
|
163
|
+
const CONTROLLER_FAILURE_STATUS = {
|
|
164
|
+
|
|
165
|
+
guidance: "Check the Settings tab to verify the controller details are correct.",
|
|
166
|
+
headline: "Unable to connect to the controller.",
|
|
167
|
+
kind: "connection-error"
|
|
168
|
+
};
|
|
169
|
+
|
|
146
170
|
/**
|
|
147
171
|
* Build the initial state. Status is `loading`; every populated-at-runtime field is set to an empty array or default value. The first {@link model:loaded}
|
|
148
172
|
* dispatch transitions every field to its loaded value in one atomic update.
|
|
@@ -157,7 +181,7 @@ export const initialState = () => {
|
|
|
157
181
|
|
|
158
182
|
// Share a single empty-array reference across every array-typed option field so the persist effect's "are we dirty?" check (configuredOptions === persistedAnchor)
|
|
159
183
|
// returns true at registration time and does not trigger a spurious initial persist. After model:loaded, each field is set to the loaded array's reference; the
|
|
160
|
-
//
|
|
184
|
+
// guarantee is preserved.
|
|
161
185
|
const empty = [];
|
|
162
186
|
|
|
163
187
|
return {
|
|
@@ -166,7 +190,10 @@ export const initialState = () => {
|
|
|
166
190
|
configuredOptions: empty,
|
|
167
191
|
controllers: [],
|
|
168
192
|
devices: [],
|
|
193
|
+
devicesAppliedSeq: 0,
|
|
169
194
|
devicesControllerId: null,
|
|
195
|
+
devicesRequest: null,
|
|
196
|
+
devicesRequestSeq: 0,
|
|
170
197
|
filter: { mode: "all", query: "" },
|
|
171
198
|
initialOptions: empty,
|
|
172
199
|
mode: "device-only",
|
|
@@ -215,12 +242,42 @@ export const reducer = (state, action) => {
|
|
|
215
242
|
return { ...state, controllers: action.controllers };
|
|
216
243
|
}
|
|
217
244
|
|
|
245
|
+
case "devices:requested": {
|
|
246
|
+
|
|
247
|
+
// Mint the next monotonic fetch sequence and record it as the pending request. The sequence - not the controllerId - is the fetch identity, so two in-flight
|
|
248
|
+
// fetches for the same controller (a re-click, or a click racing the initial fetch) still resolve last-request-wins. The latest request owns the pending slot;
|
|
249
|
+
// an earlier in-flight fetch's outcome finds its sequence superseded when it lands.
|
|
250
|
+
const seq = state.devicesRequestSeq + 1;
|
|
251
|
+
|
|
252
|
+
return { ...state, devicesRequest: { controllerId: action.controllerId ?? null, seq }, devicesRequestSeq: seq };
|
|
253
|
+
}
|
|
254
|
+
|
|
218
255
|
case "devices:loaded": {
|
|
219
256
|
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
|
|
257
|
+
// A device fetch's outcome, stamped with the sequence its `devices:requested` minted. Apply it only when it answers the pending request; a superseded or
|
|
258
|
+
// seq-less outcome vanishes here so a stale continuation cannot clobber the current view, and tests can assert reference equality on the dropped path. The null
|
|
259
|
+
// check is explicit: an optional-chained comparison would read undefined on both sides for a seq-less action against no pending request and wrongly apply it,
|
|
260
|
+
// silently green-lighting an unpaired legacy fixture.
|
|
261
|
+
if((state.devicesRequest === null) || (action.seq !== state.devicesRequest.seq)) {
|
|
262
|
+
|
|
263
|
+
return state;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// The pending request is answered. Record the applied sequence as the reducer's own verdict fact (dispatchers read it back to gate their follow-ups), clear the
|
|
267
|
+
// pending slot, and adopt the device list with its owning controller (null in device-only mode) so the association survives a later move to global scope.
|
|
268
|
+
const applied = {
|
|
269
|
+
|
|
270
|
+
...state,
|
|
271
|
+
devices: action.devices,
|
|
272
|
+
devicesAppliedSeq: action.seq,
|
|
273
|
+
devicesControllerId: action.controllerId ?? null,
|
|
274
|
+
devicesRequest: null
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
// A non-empty error is the connection-failure signal: the outcome carried an empty device list and the per-fetch failure message alongside it, so the status
|
|
278
|
+
// moves to connection-error at this, the reducer's one fetch-failure transition, layering the message onto the shared controller-failure copy. Scope is not
|
|
279
|
+
// touched here - a dispatcher moves the selection separately.
|
|
280
|
+
return action.error.length ? { ...applied, status: { ...CONTROLLER_FAILURE_STATUS, message: action.error } } : applied;
|
|
224
281
|
}
|
|
225
282
|
|
|
226
283
|
case "scope:changed": {
|
|
@@ -304,9 +361,9 @@ export const reducer = (state, action) => {
|
|
|
304
361
|
|
|
305
362
|
case "connection:error": {
|
|
306
363
|
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
return { ...state, status: { kind: "connection-error", message: action.message } };
|
|
364
|
+
// The config re-sync failed before the page could render. The orchestrator dispatches this with the full display copy for the connection-error view (headline,
|
|
365
|
+
// guidance, message); the reducer's own fetch-failure transition on devices:loaded is the other supplier of this variant.
|
|
366
|
+
return { ...state, status: { guidance: action.guidance, headline: action.headline, kind: "connection-error", message: action.message } };
|
|
310
367
|
}
|
|
311
368
|
|
|
312
369
|
default: {
|
|
@@ -24,7 +24,7 @@ const ModuleCustomEvent = CustomEvent;
|
|
|
24
24
|
* cleanup follows the caller's {@link AbortSignal} automatically, no `destroy()` ceremony required. The signal-keyed lifecycle is the same primitive every
|
|
25
25
|
* other part of the webUI already uses for its DOM listeners, so the store's subscription model integrates without a new lifecycle concept.
|
|
26
26
|
* - **Read-then-dispatch ordering.** The reducer applies before the event fires, so a subscriber reading `store.state` from inside its handler always sees the
|
|
27
|
-
* post-dispatch state. The ordering
|
|
27
|
+
* post-dispatch state. The ordering matters: views that re-derive their DOM in response to an event must see the new state, not the pre-mutation
|
|
28
28
|
* state that triggered the dispatch.
|
|
29
29
|
*
|
|
30
30
|
* The store carries no domain logic itself - the reducer (passed at construction) and the action vocabulary (consumed by subscribers) are the application-specific
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/* Copyright(C) 2017-2026, HJD (https://github.com/hjdhjd). All rights reserved.
|
|
2
2
|
*
|
|
3
|
-
* webUi-featureOptions/utils.mjs: Shared utilities for the feature
|
|
3
|
+
* webUi-featureOptions/utils.mjs: Shared utilities for the plugin webUI - used by the feature-options components and the top-level orchestrator alike.
|
|
4
4
|
*/
|
|
5
5
|
"use strict";
|
|
6
6
|
|
|
@@ -198,3 +198,27 @@ export function showToast(message, variant = "alert-success") {
|
|
|
198
198
|
setTimeout(() => toast.remove(), 150);
|
|
199
199
|
}, 3000);
|
|
200
200
|
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Extract a user-facing message from an arbitrary thrown value. The webUI's extension points - caller-supplied first-run hooks, plugin device fetchers, the
|
|
204
|
+
* connection-error retry callback, the config re-sync - can reject with any shape (an Error, a string, a plain object, a primitive), so the message is extracted
|
|
205
|
+
* defensively: `err?.message` when the value carries one, a string coercion of the whole value otherwise. This is the single error-to-text truth the webUI shares,
|
|
206
|
+
* so a toast, a nav-view connection-error message, and the sync-failure copy all read the same thrown value the same way.
|
|
207
|
+
*
|
|
208
|
+
* @param {*} err - The thrown value to describe.
|
|
209
|
+
* @returns {string} The extracted message.
|
|
210
|
+
*/
|
|
211
|
+
export function errorMessage(err) {
|
|
212
|
+
|
|
213
|
+
return err?.message ?? String(err);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Surface an arbitrary thrown value as an error toast. Routes the value through {@link errorMessage} so the toast text stays useful regardless of what bubbled out.
|
|
218
|
+
*
|
|
219
|
+
* @param {*} err - The thrown value to surface.
|
|
220
|
+
*/
|
|
221
|
+
export function toastError(err) {
|
|
222
|
+
|
|
223
|
+
homebridge.toast.error(errorMessage(err), "Error");
|
|
224
|
+
}
|
|
@@ -10,15 +10,16 @@ import { effect } from "../store.mjs";
|
|
|
10
10
|
/**
|
|
11
11
|
* Mount the connection-error view.
|
|
12
12
|
*
|
|
13
|
-
* Subscribes to `connection:error` and `model:loaded`. On `model:loaded` this view yields - it aborts its retry window and stops rendering - and the
|
|
14
|
-
* `#headerInfo` container is reclaimed by the header view; it does not itself clear the error display.
|
|
13
|
+
* Subscribes to `connection:error`, `devices:loaded`, and `model:loaded`. On `model:loaded` this view yields - it aborts its retry window and stops rendering - and the
|
|
14
|
+
* shared `#headerInfo` container is reclaimed by the header view; it does not itself clear the error display.
|
|
15
15
|
*
|
|
16
|
-
* Renders into the same `#headerInfo` container the priority-chain header uses. The two views coordinate via the `state.status`
|
|
16
|
+
* Renders into the same `#headerInfo` container the priority-chain header uses. The two views coordinate via the `state.status` tag: header yields when
|
|
17
17
|
* status is connection-error; this view yields when status is anything else.
|
|
18
18
|
*
|
|
19
19
|
* Renders:
|
|
20
20
|
*
|
|
21
|
-
* - An error
|
|
21
|
+
* - An error block whose headline, guidance, and message all come from the `connection-error` status. The caller - the reducer's fetch-failure transition or the
|
|
22
|
+
* orchestrator's config-sync-failure dispatch - supplies the full display copy, so this view maps the three text slots without hardcoding any prose.
|
|
22
23
|
* - A retry button, initially disabled, that becomes enabled after `retryDelayMs` milliseconds. The delay is a brief throttle so the user does not retry-bash a
|
|
23
24
|
* recovering controller.
|
|
24
25
|
* - A progress bar that fills during the retry-delay window so the user has visual feedback that the retry button is coming alive.
|
|
@@ -38,13 +39,28 @@ export const mountConnectionErrorView = ({ onRetry, retryDelayMs = 5000, root, s
|
|
|
38
39
|
// retry button does not linger after the user navigates away.
|
|
39
40
|
let retryAbort = null;
|
|
40
41
|
|
|
42
|
+
// The last status object this view acted on. The reducer mints a new status object only on a genuine transition, so a `devices:loaded` that did not move the status
|
|
43
|
+
// leaves this reference unchanged and the effect below skips - a dropped or successful device outcome neither tears down an armed retry window nor restarts its
|
|
44
|
+
// progress animation.
|
|
45
|
+
let lastStatus;
|
|
46
|
+
|
|
41
47
|
effect({
|
|
42
48
|
|
|
43
|
-
events: [ "connection:error", "model:loaded" ],
|
|
49
|
+
events: [ "connection:error", "devices:loaded", "model:loaded" ],
|
|
44
50
|
fn: () => {
|
|
45
51
|
|
|
46
52
|
const { status } = store.state;
|
|
47
53
|
|
|
54
|
+
// Skip when the status is reference-identical to the one already acted on. The subscription includes `devices:loaded` because the reducer folds its
|
|
55
|
+
// fetch-failure transition into that action - without the subscription the folded error would never render the retry UI - and this guard keeps a successful or
|
|
56
|
+
// dropped device outcome, which does not touch the status, from resetting the retry window that a live connection-error is showing.
|
|
57
|
+
if(status === lastStatus) {
|
|
58
|
+
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
lastStatus = status;
|
|
63
|
+
|
|
48
64
|
// Tear down any prior retry window before either rendering a new one or yielding back to the header view.
|
|
49
65
|
retryAbort?.abort();
|
|
50
66
|
retryAbort = null;
|
|
@@ -55,7 +71,7 @@ export const mountConnectionErrorView = ({ onRetry, retryDelayMs = 5000, root, s
|
|
|
55
71
|
}
|
|
56
72
|
|
|
57
73
|
retryAbort = new AbortController();
|
|
58
|
-
renderError({ message: status.message, onRetry, retryDelayMs, retrySignal: retryAbort.signal, root });
|
|
74
|
+
renderError({ guidance: status.guidance, headline: status.headline, message: status.message, onRetry, retryDelayMs, retrySignal: retryAbort.signal, root });
|
|
59
75
|
},
|
|
60
76
|
signal,
|
|
61
77
|
store
|
|
@@ -68,15 +84,16 @@ export const mountConnectionErrorView = ({ onRetry, retryDelayMs = 5000, root, s
|
|
|
68
84
|
}, { once: true });
|
|
69
85
|
};
|
|
70
86
|
|
|
71
|
-
// Render the error block into the root container. Builds the structural pieces (
|
|
72
|
-
// supplied retry signal.
|
|
73
|
-
|
|
87
|
+
// Render the error block into the root container. Builds the structural pieces (headline, guidance, the failure message, retry button, progress bar) and arms the
|
|
88
|
+
// retry window via the supplied retry signal. The three text slots - headline, guidance, message - are caller-supplied through the status, so this view holds no
|
|
89
|
+
// hardcoded failure prose of its own.
|
|
90
|
+
const renderError = ({ guidance, headline, message, onRetry, retryDelayMs, retrySignal, root }) => {
|
|
74
91
|
|
|
75
92
|
const errorBlock = createElement("div", {}, [
|
|
76
93
|
|
|
77
|
-
|
|
94
|
+
headline,
|
|
78
95
|
createElement("br"),
|
|
79
|
-
|
|
96
|
+
guidance,
|
|
80
97
|
createElement("br"),
|
|
81
98
|
createElement("code", { classList: ["text-danger"] }, [message]),
|
|
82
99
|
createElement("br")
|
|
@@ -34,6 +34,13 @@ export const mountDeviceInfoView = ({ infoPanel = defaultInfoPanel, root, signal
|
|
|
34
34
|
events: [ "scope:changed", "devices:loaded", "model:loaded" ],
|
|
35
35
|
fn: () => {
|
|
36
36
|
|
|
37
|
+
// Skip the pre-model mount. The orchestrator mounts every view before model:loaded fires, so this view's immediate-run pass would otherwise render against the
|
|
38
|
+
// loading placeholder - work the model:loaded pass immediately redoes. The sibling views carry the same guard.
|
|
39
|
+
if(store.state.status.kind === "loading") {
|
|
40
|
+
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
37
44
|
// The view populates its region but never reveals it; the orchestrator owns region visibility (revealRegions on the success path), so the device-stats panel
|
|
38
45
|
// appears together with the rest of the populated UI rather than the moment this view mounts.
|
|
39
46
|
render(infoPanel);
|
|
@@ -14,7 +14,8 @@ import { effect } from "../store.mjs";
|
|
|
14
14
|
* device-only mode. Bold lead-in text frames it as a precedence statement; color-coded labels (warning / success / info) match the same scope-color convention the
|
|
15
15
|
* row labels use, so users have one consistent visual lens for "where does a setting come from."
|
|
16
16
|
*
|
|
17
|
-
* Runs on `model:loaded` and `
|
|
17
|
+
* Runs on `model:loaded`, `connection:error`, and `devices:loaded`; other dispatches never invoke it because they are not subscribed, and a subscribed dispatch
|
|
18
|
+
* that leaves the status reference unchanged skips via the memo below. On `connection:error` (and the loading
|
|
18
19
|
* status), `fn` yields inside its `status.kind` checks. In practice the header content is rendered once, at `model:loaded`. The connection-error and no-controllers
|
|
19
20
|
* views render their own content into the same container, so this view yields when the status indicates either of those states.
|
|
20
21
|
*
|
|
@@ -25,22 +26,38 @@ import { effect } from "../store.mjs";
|
|
|
25
26
|
*/
|
|
26
27
|
export const mountHeaderView = ({ root, signal, store }) => {
|
|
27
28
|
|
|
29
|
+
// The last status object this view acted on. The reducer mints a new status object only on a genuine transition, so a `devices:loaded` that did not move the status
|
|
30
|
+
// (a successful fetch, or a dropped stale outcome that returns the identical state) leaves this reference unchanged and the effect below skips - the precedence
|
|
31
|
+
// chain is never rebuilt for a device-list change it does not depend on.
|
|
32
|
+
let lastStatus;
|
|
33
|
+
|
|
28
34
|
effect({
|
|
29
35
|
|
|
30
|
-
events: [ "
|
|
36
|
+
events: [ "connection:error", "devices:loaded", "model:loaded" ],
|
|
31
37
|
fn: () => {
|
|
32
38
|
|
|
33
39
|
const { mode, status } = store.state;
|
|
34
40
|
|
|
41
|
+
// Skip when the status is reference-identical to the one already acted on: this view renders from `mode` and `status`, and `mode` is fixed after model:loaded,
|
|
42
|
+
// so an unchanged status means nothing to redo. The subscription includes `devices:loaded` because the reducer folds its fetch-failure transition into that
|
|
43
|
+
// action - without the subscription the header would never yield to the connection-error view on a failed fetch - and this guard keeps every successful or
|
|
44
|
+
// dropped device outcome from needlessly rebuilding the chain.
|
|
45
|
+
if(status === lastStatus) {
|
|
46
|
+
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
lastStatus = status;
|
|
51
|
+
|
|
35
52
|
// Yield to the connection-error view when an error is active - that view owns the header content in error states.
|
|
36
53
|
if(status.kind === "connection-error") {
|
|
37
54
|
|
|
38
55
|
return;
|
|
39
56
|
}
|
|
40
57
|
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
58
|
+
// Yield on the "loading" status at mount time. The orchestrator mounts every view before model:loaded fires - so the connection-error view exists to render a
|
|
59
|
+
// sync failure - which means this view's immediate-run pass sees the loading placeholder and must not render the precedence chain against an empty model. The
|
|
60
|
+
// model:loaded dispatch fires this effect again with a ready status, which is when the chain actually renders.
|
|
44
61
|
if(status.kind === "loading") {
|
|
45
62
|
|
|
46
63
|
return;
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
"use strict";
|
|
6
6
|
|
|
7
|
-
import { createElement } from "../utils.mjs";
|
|
7
|
+
import { createElement, errorMessage } from "../utils.mjs";
|
|
8
8
|
import { effect } from "../store.mjs";
|
|
9
9
|
|
|
10
10
|
/**
|
|
@@ -28,23 +28,25 @@ import { effect } from "../store.mjs";
|
|
|
28
28
|
* - `scope:changed` - update active-link highlighting without rebuilding.
|
|
29
29
|
* - `model:loaded` - initial build (controllers + global link + mode-aware structure).
|
|
30
30
|
*
|
|
31
|
-
* The controller-click handler does I/O: it
|
|
32
|
-
* `
|
|
33
|
-
*
|
|
31
|
+
* The controller-click handler does I/O: it records the fetch at the store (`devices:requested`, which mints the fetch sequence), calls the caller-supplied
|
|
32
|
+
* `getDevices` callback for the new controller's DeviceListResult, then stamps the outcome onto a `devices:loaded` carrying that sequence. The reducer applies the
|
|
33
|
+
* outcome only when it still answers the pending request, so the sequence is the fetch identity and the newest click owns the store: a superseded controller click's
|
|
34
|
+
* outcome - whether it resolved with devices or rejected - is dropped at the reducer rather than overwriting the newer click's rendered state. A failed fetch's
|
|
35
|
+
* message travels back on that same `devices:loaded` (empty devices, non-empty error), which the reducer turns into the connection-error transition. The handler
|
|
36
|
+
* wraps its fetch in a try/catch so a rejected fetch becomes that same outcome rather than an unhandled rejection; the view layer never silently swallows a failure.
|
|
34
37
|
*
|
|
35
38
|
* @param {Object} args
|
|
36
39
|
* @param {((controller: import("../state.mjs").Controller | null) =>
|
|
37
|
-
* Promise<
|
|
38
|
-
* - Plugin-provided fetcher
|
|
40
|
+
* Promise<import("../../webUi-featureOptions.mjs").DeviceListResult>) | undefined} args.getDevices
|
|
41
|
+
* - Plugin-provided fetcher resolving a controller's DeviceListResult. Called on controller-link click.
|
|
39
42
|
* @param {string} args.labelControllers - Section header label for the controllers list.
|
|
40
43
|
* @param {string} args.labelDevices - Section header label for the devices list.
|
|
41
|
-
* @param {{ request: (path: string) => Promise<unknown> }} args.host - Homebridge bridge (used to fetch the error message on connection failure).
|
|
42
44
|
* @param {HTMLElement} args.rootControllers - The `#controllersContainer` element.
|
|
43
45
|
* @param {HTMLElement} args.rootDevices - The `#devicesContainer` element.
|
|
44
46
|
* @param {AbortSignal} args.signal - Lifecycle signal.
|
|
45
47
|
* @param {import("../store.mjs").FeatureOptionsStore} args.store - The store.
|
|
46
48
|
*/
|
|
47
|
-
export const mountNavView = ({ getDevices,
|
|
49
|
+
export const mountNavView = ({ getDevices, labelControllers, labelDevices, rootControllers, rootDevices, signal, store }) => {
|
|
48
50
|
|
|
49
51
|
// Controllers container rebuilds on model:loaded (initial mode/controllers), plus controllers:loaded - a controllers-only refresh hook not currently dispatched.
|
|
50
52
|
effect({
|
|
@@ -96,8 +98,9 @@ export const mountNavView = ({ getDevices, host, labelControllers, labelDevices,
|
|
|
96
98
|
store
|
|
97
99
|
});
|
|
98
100
|
|
|
99
|
-
// Click delegation: one listener on each container resolves the clicked nav link's `data-navigation` and dispatches the appropriate scope-change.
|
|
100
|
-
|
|
101
|
+
// Click delegation: one listener on each container resolves the clicked nav link's `data-navigation` and dispatches the appropriate scope-change. The
|
|
102
|
+
// last-request-wins race a controller click can open is owned by the reducer's fetch sequence, so the handler holds no per-mount generation state of its own.
|
|
103
|
+
const onClick = (event) => handleNavClick({ event, getDevices, signal, store });
|
|
101
104
|
|
|
102
105
|
rootControllers.addEventListener("click", onClick, { signal });
|
|
103
106
|
rootDevices.addEventListener("click", onClick, { signal });
|
|
@@ -122,7 +125,7 @@ const sectionHeader = (label) => createElement("h6", {
|
|
|
122
125
|
}, [label]);
|
|
123
126
|
|
|
124
127
|
// Append a labeled section to a container: an optional header followed by one rendered node per item. The header renders only when the section has at least one item,
|
|
125
|
-
// so a section that heads nothing emits no header. This is the single enforcement point for the "a header labels a non-empty section"
|
|
128
|
+
// so a section that heads nothing emits no header. This is the single enforcement point for the "a header labels a non-empty section" rule - it makes an orphan
|
|
126
129
|
// header (a label with no items beneath it) unrepresentable regardless of which list a caller renders, which is what keeps a fully-grouped device set from showing a
|
|
127
130
|
// standalone top-level header that labels nothing.
|
|
128
131
|
const appendSection = ({ items, label, render, root }) => {
|
|
@@ -164,7 +167,7 @@ const buildControllersList = ({ controllerLabel, mode, root, state }) => {
|
|
|
164
167
|
|
|
165
168
|
// The controllers section. In controller-based mode there is always at least one controller by the time the sidebar builds (the orchestrator shows the
|
|
166
169
|
// no-controllers message and never mounts the nav otherwise), so the non-empty guard is belt-and-suspenders here - but routing through appendSection keeps every
|
|
167
|
-
// section under one
|
|
170
|
+
// section under one rule rather than special-casing this one.
|
|
168
171
|
appendSection({
|
|
169
172
|
|
|
170
173
|
items: state.controllers,
|
|
@@ -175,7 +178,7 @@ const buildControllersList = ({ controllerLabel, mode, root, state }) => {
|
|
|
175
178
|
};
|
|
176
179
|
|
|
177
180
|
// Build the devices container. The ungrouped devices form the top-level section under the device label; each sidebarGroup forms its own section in alphabetical order.
|
|
178
|
-
// Because the device-label header renders only when there is at least one ungrouped device (the appendSection
|
|
181
|
+
// Because the device-label header renders only when there is at least one ungrouped device (the appendSection rule), a fully-grouped device set - every device
|
|
179
182
|
// carrying a sidebarGroup - shows its group headers alone, with no orphan top-level device header. Controllers are excluded from group derivation (their link lives in
|
|
180
183
|
// the controllers container above); the reserved "hidden" group excludes devices from the sidebar entirely.
|
|
181
184
|
const buildDevicesList = ({ catalog, deviceLabel, devices, root }) => {
|
|
@@ -238,8 +241,8 @@ const applyDevicesHighlight = (root, scope) => {
|
|
|
238
241
|
};
|
|
239
242
|
|
|
240
243
|
// Handle a click on any nav link. Resolves the click target's `data-navigation` and dispatches the corresponding scope-change. Controller clicks additionally
|
|
241
|
-
// fetch the new controller's
|
|
242
|
-
const handleNavClick = async ({ event, getDevices,
|
|
244
|
+
// fetch the new controller's DeviceListResult via the caller-supplied `getDevices` callback.
|
|
245
|
+
const handleNavClick = async ({ event, getDevices, signal, store }) => {
|
|
243
246
|
|
|
244
247
|
const navLink = event.target.closest(".nav-link[data-navigation]");
|
|
245
248
|
|
|
@@ -264,8 +267,8 @@ const handleNavClick = async ({ event, getDevices, host, signal, store }) => {
|
|
|
264
267
|
|
|
265
268
|
case "controller": {
|
|
266
269
|
|
|
267
|
-
// Optimistic scope update before the fetch so the sidebar highlight repaints immediately.
|
|
268
|
-
//
|
|
270
|
+
// Optimistic scope update before the fetch so the sidebar highlight repaints immediately. The fetch's outcome lands through the request/outcome pairing
|
|
271
|
+
// below - the reducer owns both the staleness decision and the failure transition - and a devices-bearing outcome selects the controller-as-device entry.
|
|
269
272
|
store.dispatch({ scope: { controllerId: deviceSerial, kind: "controller" }, type: "scope:changed" });
|
|
270
273
|
|
|
271
274
|
if(!getDevices) {
|
|
@@ -273,43 +276,47 @@ const handleNavClick = async ({ event, getDevices, host, signal, store }) => {
|
|
|
273
276
|
return;
|
|
274
277
|
}
|
|
275
278
|
|
|
279
|
+
// Record this fetch at the store's chokepoint before awaiting, then read back the minted sequence - the store's ticket for this fetch. The newest click owns
|
|
280
|
+
// the pending slot, so a superseded click's outcome finds its sequence gone when it lands and drops at the reducer.
|
|
281
|
+
store.dispatch({ controllerId: deviceSerial, type: "devices:requested" });
|
|
282
|
+
|
|
283
|
+
const seq = store.state.devicesRequest.seq;
|
|
284
|
+
|
|
276
285
|
try {
|
|
277
286
|
|
|
278
287
|
const controller = store.state.controllers.find((c) => c.serialNumber === deviceSerial);
|
|
279
|
-
const devices = await getDevices(controller ?? null);
|
|
288
|
+
const { devices, error } = await getDevices(controller ?? null);
|
|
280
289
|
|
|
290
|
+
// Bail if the page tore down; a torn-down store must not be dispatched against. Staleness itself is the reducer's job - it drops an outcome whose sequence no
|
|
291
|
+
// longer answers the pending request.
|
|
281
292
|
if(signal.aborted) {
|
|
282
293
|
|
|
283
294
|
return;
|
|
284
295
|
}
|
|
285
296
|
|
|
286
|
-
store.dispatch({ controllerId: deviceSerial, devices, type: "devices:loaded" });
|
|
287
|
-
|
|
288
|
-
if(devices.length === 0) {
|
|
289
|
-
|
|
290
|
-
// No devices returned despite a controller in scope - treat as a connection failure and surface the upstream error message via the host.
|
|
291
|
-
const message = String(await host.request("/getErrorMessage") ?? "");
|
|
292
|
-
|
|
293
|
-
if(signal.aborted) {
|
|
294
|
-
|
|
295
|
-
return;
|
|
296
|
-
}
|
|
297
|
+
store.dispatch({ controllerId: deviceSerial, devices, error, seq, type: "devices:loaded" });
|
|
297
298
|
|
|
298
|
-
|
|
299
|
+
// Gate the follow-up on the reducer's own verdict: select the controller-as-device entry only when my outcome is the one that applied, carried no failure,
|
|
300
|
+
// and returned at least one device. A superseded outcome, a connection failure (the reducer moved the store to connection-error), or an empty controller each
|
|
301
|
+
// leaves the optimistic controller scope standing with no device-scope dispatch.
|
|
302
|
+
if((store.state.devicesAppliedSeq !== seq) || error.length || (devices.length === 0)) {
|
|
299
303
|
|
|
300
304
|
return;
|
|
301
305
|
}
|
|
302
306
|
|
|
303
307
|
// Select the controller-as-device entry (the first device in the returned list).
|
|
304
308
|
store.dispatch({ scope: { controllerId: deviceSerial, deviceId: devices[0].serialNumber, kind: "device" }, type: "scope:changed" });
|
|
305
|
-
} catch {
|
|
309
|
+
} catch(err) {
|
|
306
310
|
|
|
311
|
+
// The page-teardown bail guards the reject path too. Route the rejection (an IPC failure, the contract-guard TypeError) through the same outcome channel: the
|
|
312
|
+
// reducer drops it if a newer click superseded this one, and otherwise clears the stale device list and moves the store to connection-error. A named Error
|
|
313
|
+
// reaches the user verbatim; other junk is stringified.
|
|
307
314
|
if(signal.aborted) {
|
|
308
315
|
|
|
309
316
|
return;
|
|
310
317
|
}
|
|
311
318
|
|
|
312
|
-
store.dispatch({
|
|
319
|
+
store.dispatch({ controllerId: deviceSerial, devices: [], error: errorMessage(err), seq, type: "devices:loaded" });
|
|
313
320
|
}
|
|
314
321
|
|
|
315
322
|
return;
|
|
@@ -33,7 +33,8 @@ import { effect } from "../store.mjs";
|
|
|
33
33
|
*
|
|
34
34
|
* @param {Object} args
|
|
35
35
|
* @param {HTMLElement} args.configTable - The `#configTable` element.
|
|
36
|
-
* @param {string | undefined} args.platform -
|
|
36
|
+
* @param {() => (string | undefined)} args.platform - A thunk returning the Homebridge plugin platform identifier (for localStorage key namespacing). Deferred as a
|
|
37
|
+
* thunk because the views mount before the session re-syncs, so the identifier is read inside the model:loaded effect - post-sync - rather than at mount.
|
|
37
38
|
* @param {AbortSignal} args.signal - Lifecycle signal.
|
|
38
39
|
* @param {import("../store.mjs").FeatureOptionsStore} args.store - The store.
|
|
39
40
|
*/
|
|
@@ -44,10 +45,14 @@ export const mountOptionsView = ({ configTable, platform, signal, store }) => {
|
|
|
44
45
|
let mountedKey;
|
|
45
46
|
|
|
46
47
|
// Per-view category expansion state, persisted via localStorage. The orchestrator writes the user's expand/collapse choices through this object so the disk
|
|
47
|
-
// projection survives page reloads; on re-entry to a view we apply the persisted state so the user's collapse choices stay sticky across sessions.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
// projection survives page reloads; on re-entry to a view we apply the persisted state so the user's collapse choices stay sticky across sessions. Its localStorage
|
|
49
|
+
// namespace is the platform identifier, which is only correct once the session has re-synced, so it is constructed inside the model:loaded effect below (reading the
|
|
50
|
+
// `platform` thunk post-sync) rather than at mount - the views mount before the sync resolves.
|
|
51
|
+
let categoryState;
|
|
52
|
+
|
|
53
|
+
// Rebuild on model:loaded - construct the category-state store from the freshly-synced platform, then clear any prior content and prepare for the first
|
|
54
|
+
// scope-render. The actual category shells come from the scope-render path. This effect is registered before the scope-render effect below, so on a model:loaded
|
|
55
|
+
// dispatch it runs first and `categoryState` is built before that effect reads it.
|
|
51
56
|
effect({
|
|
52
57
|
|
|
53
58
|
events: ["model:loaded"],
|
|
@@ -58,6 +63,7 @@ export const mountOptionsView = ({ configTable, platform, signal, store }) => {
|
|
|
58
63
|
return;
|
|
59
64
|
}
|
|
60
65
|
|
|
66
|
+
categoryState = new FeatureOptionsCategoryState(platform());
|
|
61
67
|
configTable.textContent = "";
|
|
62
68
|
cache.clear();
|
|
63
69
|
mountedKey = undefined;
|
|
@@ -259,7 +265,7 @@ export const mountOptionsView = ({ configTable, platform, signal, store }) => {
|
|
|
259
265
|
};
|
|
260
266
|
|
|
261
267
|
// Scope-aware cache invalidation for a per-option mutation. The action's `args.id` field carries the mutation's scope marker (the persisted entry-string format
|
|
262
|
-
// encodes scope by serial), so we
|
|
268
|
+
// encodes scope by serial), so we distinguish it by matching against the controllers list:
|
|
263
269
|
//
|
|
264
270
|
// - `id` undefined - global-scope mutation. Every cached view inherits from global. Drop every entry.
|
|
265
271
|
// - `id` matches a controller's serial - controller-scope mutation. Every cached device-view under this controller inherits from it. Drop entries whose key
|
|
@@ -375,7 +381,7 @@ const buildCategoryShells = ({ configTable, state }) => {
|
|
|
375
381
|
configTable.appendChild(fragment);
|
|
376
382
|
};
|
|
377
383
|
|
|
378
|
-
// Materialize the rows for a single category.
|
|
384
|
+
// Materialize the rows for a single category. Guarded by dataset.rowsRendered - re-opening an already-built category is a no-op for materialization.
|
|
379
385
|
const ensureRowsRendered = ({ details, state }) => {
|
|
380
386
|
|
|
381
387
|
if(details.dataset.rowsRendered === "true") {
|
|
@@ -138,7 +138,7 @@ export const mountSearchView = ({ configTable, root, signal, store }) => {
|
|
|
138
138
|
}
|
|
139
139
|
};
|
|
140
140
|
|
|
141
|
-
// Build the panel DOM and fill the `refs` record with element pointers that update functions consult later. One-time call;
|
|
141
|
+
// Build the panel DOM and fill the `refs` record with element pointers that update functions consult later. One-time call; safe to repeat only insofar as it reassigns
|
|
142
142
|
// `refs` fields (the panel itself is rebuilt fresh, replacing any prior content).
|
|
143
143
|
const buildPanel = ({ refs, root }) => {
|
|
144
144
|
|
|
@@ -350,7 +350,7 @@ const handleClick = ({ configTable, event, refs, store }) => {
|
|
|
350
350
|
// On bulk expand, synchronously drive the options view's capture-phase `toggle` handler so each category's rows materialize in the SAME layout pass as the open,
|
|
351
351
|
// rather than the open reflowing first and the async native `toggle` task materializing in a second pass. Collapsing the opens and materializations into one
|
|
352
352
|
// settled height change keeps the host's iframe-resize ResizeObserver from cascading ("ResizeObserver loop completed with undelivered notifications"). Row
|
|
353
|
-
// materialization
|
|
353
|
+
// materialization only runs once, so the native async toggle that follows is a no-op. Collapse needs no synthetic toggle - hiding rows is already one reflow.
|
|
354
354
|
if(shouldExpand) {
|
|
355
355
|
|
|
356
356
|
details.dispatchEvent(new Event("toggle"));
|