@xh/hoist 74.0.0-SNAPSHOT.1748547679578 → 74.0.0-SNAPSHOT.1748629362620

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/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## 74.0.0-SNAPSHOT - unreleased
4
4
 
5
+ ### 💥 Breaking Changes (upgrade difficulty: 🟢 LOW - minor change to ViewManagerModel)
6
+
7
+ * Removed `ViewManagerModel.settleTime`. Now set via individual `PersistOptions.settleTime` instead.
8
+
9
+ ### 🎁 New Features
10
+ * Added `ViewManagerModel.preserveUnsavedChanges` flag to opt-out of that behaviour.
11
+ * Added `PersistOptions.settleTime` to configure time to wait for state to settle before persisting.
12
+
13
+ ### 🐞 Bug Fixes
14
+ * Improved `ViewManagerModel.settleTime` by delegating to individual `PersistenceProviders`.
15
+ * Fixed bug where grid column state could become unintentionally dirty when columns were hidden.
16
+
5
17
  ## v73.0.1 - 2025-05-19
6
18
 
7
19
  ### 🐞 Bug Fixes
@@ -43,6 +43,11 @@ export interface ViewManagerConfig {
43
43
  * True (default) to allow users to share their views with other users.
44
44
  */
45
45
  enableSharing?: boolean;
46
+ /**
47
+ * True (default) to save pending state to SessionStorage so that it can be restored across
48
+ * browser refreshes. Unlike auto-save, this does not write to the database.
49
+ */
50
+ preserveUnsavedChanges?: boolean;
46
51
  /**
47
52
  * Function to determine the initial view for a user, when no view has already been persisted.
48
53
  * Will be passed a list of views available to the current user. Implementations where
@@ -52,13 +57,6 @@ export interface ViewManagerConfig {
52
57
  * Must be set when enableDefault is false.
53
58
  */
54
59
  initialViewSpec?: (views: ViewInfo[]) => ViewInfo;
55
- /**
56
- * Delay (in ms) to wait after state has been set on associated components before listening for
57
- * further state changes. The long default wait 1000ms is intended to avoid a false positive
58
- * dirty indicator when linking to complex components such as dashboards or grids that can
59
- * report immediate changes to state due to internal processing or rendering.
60
- */
61
- settleTime?: number;
62
60
  /**
63
61
  * True to allow the user to publish or edit the global views. Apps are expected to
64
62
  * commonly set this based on user roles - e.g. `XH.getUser().hasRole('MANAGE_GRID_VIEWS')`.
@@ -132,8 +130,8 @@ export declare class ViewManagerModel<T = PlainObject> extends HoistModel {
132
130
  readonly enableDefault: boolean;
133
131
  readonly enableGlobal: boolean;
134
132
  readonly enableSharing: boolean;
133
+ readonly preserveUnsavedChanges: boolean;
135
134
  readonly manageGlobal: boolean;
136
- readonly settleTime: number;
137
135
  readonly initialViewSpec: (views: ViewInfo[]) => ViewInfo;
138
136
  /** Current view. Will not include uncommitted changes */
139
137
  view: View<T>;
@@ -161,16 +159,12 @@ export declare class ViewManagerModel<T = PlainObject> extends HoistModel {
161
159
  /** Unsaved changes on the current view.*/
162
160
  private pendingValue;
163
161
  /**
164
- * Array of {@link ViewManagerProvider} instances bound to this model. Providers will
165
- * push themselves onto this array when constructed with a reference to this model. Used to
166
- * proactively push state to the target components when the model's selected `value` changes.
167
- * @internal
162
+ * Array of {@link ViewManagerProvider} instances bound to this model. Used to proactively push
163
+ * state to the target components when the model's selected `value` changes.
168
164
  */
169
- providers: ViewManagerProvider<any>[];
165
+ private providers;
170
166
  /** Data access for persisting views. */
171
167
  private dataAccess;
172
- /** Last time changes were pushed to linked persistence providers */
173
- private lastPushed;
174
168
  get isValueDirty(): boolean;
175
169
  get isViewSavable(): boolean;
176
170
  get isViewAutoSavable(): boolean;
@@ -207,8 +201,18 @@ export declare class ViewManagerModel<T = PlainObject> extends HoistModel {
207
201
  /** Promote a view to global visibility/ownership status. */
208
202
  makeViewGlobalAsync(view: ViewInfo): Promise<View<T>>;
209
203
  deleteViewsAsync(toDelete: ViewInfo[]): Promise<void>;
204
+ /**
205
+ * Called by {@link ViewManagerProvider} to receive state changes from this model.
206
+ * @internal
207
+ */
208
+ registerProvider(provider: ViewManagerProvider<any>): void;
209
+ /**
210
+ * Called by {@link ViewManagerProvider} to stop receiving state changes.
211
+ * @internal
212
+ */
213
+ unregisterProvider(provider: ViewManagerProvider<any>): void;
210
214
  private initAsync;
211
- private pendingValueReaction;
215
+ private unsavedChangesReaction;
212
216
  private autoSaveReaction;
213
217
  private stateReactions;
214
218
  private loadViewAsync;
@@ -11,6 +11,10 @@ export interface PersistOptions {
11
11
  path?: string;
12
12
  /** Debounce interval in ms, or a lodash debounce config. */
13
13
  debounce?: DebounceSpec;
14
+ /**
15
+ * Delay (in ms) to wait after state has been read before listening for further state changes.
16
+ */
17
+ settleTime?: number;
14
18
  /**
15
19
  * Type of PersistenceProvider to create. Specify as one of the built-in string types,
16
20
  * or a subclass of PersistenceProvider.
@@ -26,9 +26,12 @@ export declare abstract class PersistenceProvider<S = any> {
26
26
  readonly path: string;
27
27
  readonly debounce: DebounceSpec;
28
28
  readonly owner: HoistBase;
29
+ readonly settleTime: number;
29
30
  protected target: Persistable<S>;
30
31
  protected defaultState: PersistableState<S>;
31
32
  private disposer;
33
+ private lastReadState;
34
+ private lastReadTime;
32
35
  /**
33
36
  * Construct an instance of this class.
34
37
  *
@@ -5,9 +5,9 @@
5
5
  * Copyright © 2025 Extremely Heavy Industries Inc.
6
6
  */
7
7
  import {PersistableState, PersistenceProvider} from '@xh/hoist/core';
8
- import {isObject} from 'lodash';
8
+ import {isEqual, isObject} from 'lodash';
9
9
  import {GridModel} from '../GridModel';
10
- import {GridModelPersistOptions} from '../Types';
10
+ import {ColumnState, GridModelPersistOptions} from '../Types';
11
11
 
12
12
  /**
13
13
  * Initialize persistence for a {@link GridModel} by applying its `persistWith` config.
@@ -33,7 +33,8 @@ export function initPersist(
33
33
  ...persistWith
34
34
  },
35
35
  target: {
36
- getPersistableState: () => new PersistableState(gridModel.persistableColumnState),
36
+ getPersistableState: () =>
37
+ new PersistableColumnState(gridModel.persistableColumnState),
37
38
  setPersistableState: ({value}) => gridModel.setColumnState(value)
38
39
  },
39
40
  owner: gridModel
@@ -75,3 +76,12 @@ export function initPersist(
75
76
  });
76
77
  }
77
78
  }
79
+
80
+ class PersistableColumnState extends PersistableState<ColumnState[]> {
81
+ override equals(other: PersistableState<ColumnState[]>): boolean {
82
+ return isEqual(
83
+ this.value.filter(it => !it.hidden),
84
+ other.value.filter(it => !it.hidden)
85
+ );
86
+ }
87
+ }
@@ -19,7 +19,7 @@ import type {ViewManagerProvider, ReactionSpec} from '@xh/hoist/core';
19
19
  import {genDisplayName} from '@xh/hoist/data';
20
20
  import {fmtDateTime} from '@xh/hoist/format';
21
21
  import {action, bindable, makeObservable, observable, comparer, runInAction} from '@xh/hoist/mobx';
22
- import {olderThan, SECONDS} from '@xh/hoist/utils/datetime';
22
+ import {SECONDS} from '@xh/hoist/utils/datetime';
23
23
  import {executeIfFunction, pluralize, throwIf} from '@xh/hoist/utils/js';
24
24
  import {find, isEqual, isNil, isNull, isObject, isUndefined, lowerCase, uniqBy} from 'lodash';
25
25
  import {ReactNode} from 'react';
@@ -74,6 +74,12 @@ export interface ViewManagerConfig {
74
74
  */
75
75
  enableSharing?: boolean;
76
76
 
77
+ /**
78
+ * True (default) to save pending state to SessionStorage so that it can be restored across
79
+ * browser refreshes. Unlike auto-save, this does not write to the database.
80
+ */
81
+ preserveUnsavedChanges?: boolean;
82
+
77
83
  /**
78
84
  * Function to determine the initial view for a user, when no view has already been persisted.
79
85
  * Will be passed a list of views available to the current user. Implementations where
@@ -84,14 +90,6 @@ export interface ViewManagerConfig {
84
90
  */
85
91
  initialViewSpec?: (views: ViewInfo[]) => ViewInfo;
86
92
 
87
- /**
88
- * Delay (in ms) to wait after state has been set on associated components before listening for
89
- * further state changes. The long default wait 1000ms is intended to avoid a false positive
90
- * dirty indicator when linking to complex components such as dashboards or grids that can
91
- * report immediate changes to state due to internal processing or rendering.
92
- */
93
- settleTime?: number;
94
-
95
93
  /**
96
94
  * True to allow the user to publish or edit the global views. Apps are expected to
97
95
  * commonly set this based on user roles - e.g. `XH.getUser().hasRole('MANAGE_GRID_VIEWS')`.
@@ -176,8 +174,8 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
176
174
  readonly enableDefault: boolean;
177
175
  readonly enableGlobal: boolean;
178
176
  readonly enableSharing: boolean;
177
+ readonly preserveUnsavedChanges: boolean;
179
178
  readonly manageGlobal: boolean;
180
- readonly settleTime: number;
181
179
  readonly initialViewSpec: (views: ViewInfo[]) => ViewInfo;
182
180
 
183
181
  /** Current view. Will not include uncommitted changes */
@@ -216,19 +214,14 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
216
214
  private pendingValue: PendingValue<T> = null;
217
215
 
218
216
  /**
219
- * Array of {@link ViewManagerProvider} instances bound to this model. Providers will
220
- * push themselves onto this array when constructed with a reference to this model. Used to
221
- * proactively push state to the target components when the model's selected `value` changes.
222
- * @internal
217
+ * Array of {@link ViewManagerProvider} instances bound to this model. Used to proactively push
218
+ * state to the target components when the model's selected `value` changes.
223
219
  */
224
- providers: ViewManagerProvider<any>[] = [];
220
+ private providers: ViewManagerProvider<any>[] = [];
225
221
 
226
222
  /** Data access for persisting views. */
227
223
  private dataAccess: DataAccess<T>;
228
224
 
229
- /** Last time changes were pushed to linked persistence providers */
230
- private lastPushed: number = null;
231
-
232
225
  //---------------
233
226
  // Getters
234
227
  //---------------
@@ -296,7 +289,7 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
296
289
  enableDefault = true,
297
290
  enableGlobal = true,
298
291
  enableSharing = true,
299
- settleTime = 1000,
292
+ preserveUnsavedChanges = true,
300
293
  initialViewSpec = null
301
294
  }: ViewManagerConfig) {
302
295
  super();
@@ -317,7 +310,7 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
317
310
  this.enableGlobal = enableGlobal;
318
311
  this.enableSharing = enableSharing;
319
312
  this.enableAutoSave = enableAutoSave;
320
- this.settleTime = settleTime;
313
+ this.preserveUnsavedChanges = preserveUnsavedChanges;
321
314
  this.initialViewSpec = initialViewSpec;
322
315
 
323
316
  this.selectTask = TaskObserver.trackLast({
@@ -421,10 +414,7 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
421
414
 
422
415
  @action
423
416
  setValue(value: Partial<T>) {
424
- const {view, pendingValue, lastPushed, settleTime} = this;
425
- if (!pendingValue && settleTime && !olderThan(lastPushed, settleTime)) {
426
- return;
427
- }
417
+ const {view, pendingValue} = this;
428
418
 
429
419
  value = this.cleanState(value);
430
420
  if (!isEqual(value, view.value)) {
@@ -500,6 +490,25 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
500
490
  if (exception) throw exception;
501
491
  }
502
492
 
493
+ //------------------
494
+ // Internal
495
+ //------------------
496
+ /**
497
+ * Called by {@link ViewManagerProvider} to receive state changes from this model.
498
+ * @internal
499
+ */
500
+ registerProvider(provider: ViewManagerProvider<any>) {
501
+ this.providers.push(provider);
502
+ }
503
+
504
+ /**
505
+ * Called by {@link ViewManagerProvider} to stop receiving state changes.
506
+ * @internal
507
+ */
508
+ unregisterProvider(provider: ViewManagerProvider<any>) {
509
+ this.providers = this.providers.filter(it => it !== provider);
510
+ }
511
+
503
512
  //------------------
504
513
  // Implementation
505
514
  //------------------
@@ -515,7 +524,9 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
515
524
  this.views = views;
516
525
  this.userPinned = state.userPinned;
517
526
  this.autoSave = state.autoSave;
518
- this.pendingValue = XH.sessionStorageService.get(pendingValueStorageKey);
527
+ if (this.preserveUnsavedChanges) {
528
+ this.pendingValue = XH.sessionStorageService.get(pendingValueStorageKey);
529
+ }
519
530
  });
520
531
 
521
532
  // 2) Initialize/choose initial view. Null is ok, and will yield default.
@@ -537,13 +548,13 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
537
548
  }
538
549
 
539
550
  this.addReaction(
540
- this.pendingValueReaction(),
551
+ this.preserveUnsavedChanges ? this.unsavedChangesReaction() : null,
541
552
  this.autoSaveReaction(),
542
553
  ...this.stateReactions(initialState)
543
554
  );
544
555
  }
545
556
 
546
- private pendingValueReaction(): ReactionSpec {
557
+ private unsavedChangesReaction(): ReactionSpec {
547
558
  return {
548
559
  track: () => this.pendingValue,
549
560
  run: v => XH.sessionStorageService.set(this.pendingValueStorageKey, v)
@@ -588,7 +599,6 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
588
599
  .thenAction(latest => {
589
600
  this.setAsView(latest, pendingValue?.token == token ? pendingValue : null);
590
601
  this.providers.forEach(it => it.pushStateToTarget());
591
- this.lastPushed = Date.now();
592
602
  })
593
603
  .linkTo(this.selectTask);
594
604
  }
@@ -622,6 +632,9 @@ export class ViewManagerModel<T = PlainObject> extends HoistModel {
622
632
  if (!view.isDefault) {
623
633
  this.views = uniqBy([view.info, ...this.views], 'token');
624
634
  }
635
+
636
+ // Ensure providers have a clean reference of the current view state.
637
+ this.providers.forEach(it => it.read());
625
638
  }
626
639
 
627
640
  private handleException(e, opts: ExceptionHandlerOptions = {}) {
@@ -28,6 +28,11 @@ export interface PersistOptions {
28
28
  /** Debounce interval in ms, or a lodash debounce config. */
29
29
  debounce?: DebounceSpec;
30
30
 
31
+ /**
32
+ * Delay (in ms) to wait after state has been read before listening for further state changes.
33
+ */
34
+ settleTime?: number;
35
+
31
36
  /**
32
37
  * Type of PersistenceProvider to create. Specify as one of the built-in string types,
33
38
  * or a subclass of PersistenceProvider.
@@ -5,6 +5,7 @@
5
5
  * Copyright © 2025 Extremely Heavy Industries Inc.
6
6
  */
7
7
 
8
+ import {olderThan} from '@xh/hoist/utils/datetime';
8
9
  import {logDebug, logError, throwIf} from '@xh/hoist/utils/js';
9
10
  import {
10
11
  cloneDeep,
@@ -59,11 +60,14 @@ export abstract class PersistenceProvider<S = any> {
59
60
  readonly path: string;
60
61
  readonly debounce: DebounceSpec;
61
62
  readonly owner: HoistBase;
63
+ readonly settleTime: number;
62
64
 
63
65
  protected target: Persistable<S>;
64
66
  protected defaultState: PersistableState<S>;
65
67
 
66
68
  private disposer: IReactionDisposer;
69
+ private lastReadState: PersistableState<S>;
70
+ private lastReadTime: number;
67
71
 
68
72
  /**
69
73
  * Construct an instance of this class.
@@ -126,7 +130,10 @@ export abstract class PersistenceProvider<S = any> {
126
130
  read(): PersistableState<S> {
127
131
  const state = get(this.readRaw(), this.path);
128
132
  logDebug(['Reading state', state], this.owner);
129
- return !isUndefined(state) ? new PersistableState(state) : null;
133
+ const ret = !isUndefined(state) ? new PersistableState(state) : null;
134
+ this.lastReadState = ret;
135
+ this.lastReadTime = Date.now();
136
+ return ret;
130
137
  }
131
138
 
132
139
  /** Persist JSON-serializable state to this provider's path. */
@@ -161,11 +168,12 @@ export abstract class PersistenceProvider<S = any> {
161
168
  const {owner, persistOptions} = cfg;
162
169
  this.owner = owner;
163
170
 
164
- const {path, debounce = 250} = persistOptions;
171
+ const {path, debounce = 250, settleTime} = persistOptions;
165
172
  throwIf(!path, 'Path not specified in PersistenceProvider.');
166
173
 
167
174
  this.path = path;
168
175
  this.debounce = debounce;
176
+ this.settleTime = settleTime;
169
177
  this.owner.markManaged(this);
170
178
 
171
179
  if (debounce) {
@@ -187,8 +195,14 @@ export abstract class PersistenceProvider<S = any> {
187
195
  this.disposer = reaction(
188
196
  () => this.target.getPersistableState(),
189
197
  state => {
190
- if (state.equals(this.defaultState)) {
198
+ if (this.settleTime && !olderThan(this.lastReadTime, this.settleTime)) {
199
+ return;
200
+ } else if (state.equals(this.defaultState)) {
191
201
  this.clear();
202
+ } else if (this.lastReadState && state.equals(this.lastReadState)) {
203
+ // If the last read state is equal to the current state, use the last read state
204
+ // to avoid appearing "dirty"
205
+ this.write(this.lastReadState.value);
192
206
  } else {
193
207
  this.write(state.value);
194
208
  }
@@ -6,7 +6,6 @@
6
6
  */
7
7
 
8
8
  import {throwIf} from '@xh/hoist/utils/js';
9
- import {pull} from 'lodash';
10
9
  import {PersistenceProvider, PersistenceProviderConfig} from '../PersistenceProvider';
11
10
  import type {ViewManagerModel} from '@xh/hoist/cmp/viewmanager/ViewManagerModel';
12
11
 
@@ -18,7 +17,7 @@ export class ViewManagerProvider<S> extends PersistenceProvider<S> {
18
17
  const {viewManagerModel} = cfg.persistOptions;
19
18
  throwIf(!viewManagerModel, `ViewManagerProvider requires a 'viewManagerModel'.`);
20
19
  this.viewManagerModel = viewManagerModel;
21
- viewManagerModel.providers.push(this);
20
+ viewManagerModel.registerProvider(this);
22
21
  }
23
22
 
24
23
  pushStateToTarget() {
@@ -38,10 +37,7 @@ export class ViewManagerProvider<S> extends PersistenceProvider<S> {
38
37
  }
39
38
 
40
39
  override destroy() {
41
- if (this.viewManagerModel) {
42
- pull(this.viewManagerModel.providers, this);
43
- }
44
-
40
+ this.viewManagerModel?.unregisterProvider(this);
45
41
  super.destroy();
46
42
  }
47
43
  }
@@ -195,6 +195,7 @@ export class DashCanvasModel
195
195
  PersistenceProvider.create({
196
196
  persistOptions: {
197
197
  path: 'dashCanvas',
198
+ settleTime: 1000,
198
199
  ...persistWith
199
200
  },
200
201
  target: this
@@ -215,6 +215,7 @@ export class DashContainerModel
215
215
  PersistenceProvider.create({
216
216
  persistOptions: {
217
217
  path: 'dashContainer',
218
+ settleTime: 1000,
218
219
  ...persistWith
219
220
  },
220
221
  target: this
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xh/hoist",
3
- "version": "74.0.0-SNAPSHOT.1748547679578",
3
+ "version": "74.0.0-SNAPSHOT.1748629362620",
4
4
  "description": "Hoist add-on for building and deploying React Applications.",
5
5
  "repository": "github:xh/hoist-react",
6
6
  "homepage": "https://xh.io",
@@ -202,7 +202,9 @@ const enhancePromise = promisePrototype => {
202
202
  if (!options) return this;
203
203
 
204
204
  const startTime = Date.now(),
205
- doTrack = (isError: boolean) => {
205
+ doTrack = (exception: unknown = null) => {
206
+ if (exception && exception['isRoutine']) return;
207
+
206
208
  const endTime = Date.now(),
207
209
  opts: TrackOptions = isString(options) ? {message: options} : {...options};
208
210
  opts.timestamp = startTime;
@@ -220,18 +222,18 @@ const enhancePromise = promisePrototype => {
220
222
  ) {
221
223
  opts.elapsed = null;
222
224
  }
223
- if (isError) opts.severity = 'ERROR';
225
+ if (exception) opts.severity = 'ERROR';
224
226
 
225
227
  XH.track(opts);
226
228
  };
227
229
 
228
230
  return this.then(
229
231
  (v: T) => {
230
- doTrack(false);
232
+ doTrack();
231
233
  return v;
232
234
  },
233
235
  (t: unknown) => {
234
- doTrack(true);
236
+ doTrack(t);
235
237
  throw t;
236
238
  }
237
239
  );