@xh/hoist 86.0.0 → 86.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+
4
+ ## 86.0.1 - 2026-06-16
5
+
6
+ ### 🐞 Bug Fixes
7
+
8
+ * Fixed a circular import introduced in v86 that caused problems for apps using `@ComputeOnce`.
9
+ * Fixed a regression to favorites icon size for `FilterChooser.`
10
+
11
+ ### ⚙️ Technical
12
+
13
+ * `TrackService`, `PrefService`, and `TraceService` now flush their pending entries reliably when
14
+ the page is hidden or unloaded, reacting to `XH.pageState` and issuing the flush via
15
+ `fetch({keepalive: true})` (replacing the less reliable `beforeunload` + normal-fetch approach).
16
+
17
+
3
18
  ## 86.0.0 - 2026-06-12
4
19
 
5
20
  ### 💥 Breaking Changes (upgrade difficulty: 🟠 MEDIUM - library upgrades + component API changes)
@@ -58,9 +58,10 @@ export declare class PrefService extends HoistService {
58
58
  */
59
59
  pushAsync(key: string, value: any): Promise<void>;
60
60
  /**
61
- * Push any pending buffered updates to persist newly set values to server.
62
- * Called automatically by this app on page unload to avoid dropping changes when e.g. a user
63
- * changes and option and then immediately hits a (browser) refresh.
61
+ * Push any pending buffered updates to persist newly set values to the server.
62
+ *
63
+ * Not typically called by applications. Called automatically by the framework after changes
64
+ * and when page is hidden/terminated.
64
65
  */
65
66
  pushPendingAsync(): Promise<void>;
66
67
  private pushPendingBuffered;
@@ -1,4 +1,4 @@
1
- import { HoistService, InitContext, Span, FullSpanConfig } from '@xh/hoist/core';
1
+ import { HoistService, Span, FullSpanConfig } from '@xh/hoist/core';
2
2
  /**
3
3
  * Client-side distributed tracing service for Hoist applications.
4
4
  *
@@ -30,7 +30,7 @@ export declare class TraceService extends HoistService {
30
30
  private conf;
31
31
  /** Spans created before config available. */
32
32
  private _preConfigSpans;
33
- initAsync(ctx: InitContext): Promise<void>;
33
+ initAsync(): Promise<void>;
34
34
  /** Is tracing currently enabled? */
35
35
  get enabled(): boolean;
36
36
  /**
@@ -64,12 +64,10 @@ export declare class TraceService extends HoistService {
64
64
  */
65
65
  private exportSpan;
66
66
  /**
67
- * Push all pending spans to the server.
68
- * Called on debounced interval and on page unload.
67
+ * Flush the queue of pending spans to the server.
68
+ * @internal - apps should generally allow this service to manage w/its internal debounce.
69
69
  */
70
70
  pushPendingAsync(): Promise<void>;
71
- /** Bound the pending buffer, silently dropping oldest spans (failed pushes are logged). */
72
- private enforceCap;
73
71
  /**
74
72
  * Called by {@link ConfigService} once `xhTraceConfig` has loaded. Applies sampling
75
73
  * decisions to all spans created during early startup and held in the pendingConfig
@@ -83,6 +81,10 @@ export declare class TraceService extends HoistService {
83
81
  */
84
82
  noteConfigAvailable(): void;
85
83
  private pushPendingBuffered;
84
+ /** Flush all pending spans to the server. */
85
+ private pushPendingInternalAsync;
86
+ /** Bound the pending buffer, silently dropping oldest spans (failed pushes are logged). */
87
+ private enforceCap;
86
88
  /**
87
89
  * Resolve a root-span sampling decision: a probabilistic decision from `sampleRules`. Rules
88
90
  * match on tag keys; the reserved key `name` matches the span's name (glob-capable, same
@@ -1,4 +1,4 @@
1
- import { HoistService, InitContext, TrackOptions } from '@xh/hoist/core';
1
+ import { HoistService, TrackOptions } from '@xh/hoist/core';
2
2
  /**
3
3
  * Primary service for tracking any activity that an application's admins want to track.
4
4
  * Activities are available for viewing/querying in the Admin Console's Client Activity tab.
@@ -9,14 +9,16 @@ export declare class TrackService extends HoistService {
9
9
  static instance: TrackService;
10
10
  private oncePerSessionSent;
11
11
  private pending;
12
- initAsync(ctx: InitContext): Promise<void>;
12
+ initAsync(): Promise<void>;
13
13
  get conf(): ActivityTrackingConfig;
14
14
  get enabled(): boolean;
15
15
  /** Track User Activity. */
16
16
  track(options: TrackOptions | string): void;
17
17
  /**
18
18
  * Flush the queue of pending activity tracking messages to the server.
19
- * @internal - apps should generally allow this service to manage w/its internal debounce.
19
+ *
20
+ * Not typically called by applications. Called automatically by the framework via a
21
+ * debounce and when the page is hidden/terminated.
20
22
  */
21
23
  pushPendingAsync(): Promise<void>;
22
24
  private pushPendingBuffered;
@@ -0,0 +1,13 @@
1
+ import { CallContextLike } from '@xh/hoist/core';
2
+ import type { FetchOptions } from '../FetchService';
3
+ /**
4
+ * Post a JSON body, using `fetch({keepalive: true})` when the page is no longer visible so the
5
+ * request can survive teardown (e.g. a flush as `XH.pageState` goes `hidden`/`frozen`/`terminated`).
6
+ *
7
+ * Keepalive bodies share a single browser-wide 64KB budget; if exceeded the request is never sent
8
+ * and we retry once uncapped (without keepalive), which still completes while the page is alive (the
9
+ * common `hidden` case). Real server errors are re-thrown, not re-posted.
10
+ *
11
+ * @internal
12
+ */
13
+ export declare function terminationSafePostJson(opts: FetchOptions, ctx?: CallContextLike): Promise<any>;
@@ -1,4 +1,4 @@
1
- import { HoistProps } from '@xh/hoist/core';
1
+ import type { HoistProps } from '@xh/hoist/core';
2
2
  /**
3
3
  * HTML attribute name used to tag elements with stable test identifiers. Use as a computed
4
4
  * property key on element specs - e.g. `{[TEST_ID]: 'my-grid'}` - to emit a `data-testid`
@@ -1,4 +1,4 @@
1
- import { ResolvedLayoutProps, PlainObject } from '@xh/hoist/core';
1
+ import type { ResolvedLayoutProps, PlainObject } from '@xh/hoist/core';
2
2
  /**
3
3
  * These utils support accepting the CSS styles enumerated below as top-level props of a Component,
4
4
  * and are typically accessed via the `@LayoutSupport` mixin (for class-based components) or the
@@ -1,4 +1,4 @@
1
- import { Content } from '@xh/hoist/core';
1
+ import type { Content } from '@xh/hoist/core';
2
2
  import { ReactElement } from 'react';
3
3
  /**
4
4
  * Return the display name for either a class-based or functional Component.
@@ -93,11 +93,6 @@
93
93
 
94
94
  .xh-filter-chooser-favorite-icon {
95
95
  margin-left: var(--xh-pad-half-px);
96
-
97
- // Matched to GroupingChooser equivalent.
98
- &.svg-inline--fa {
99
- width: 12px;
100
- }
101
96
  }
102
97
 
103
98
  .xh-filter-chooser-favorite {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xh/hoist",
3
- "version": "86.0.0",
3
+ "version": "86.0.1",
4
4
  "description": "Hoist add-on for building and deploying React Applications.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -38,8 +38,8 @@
38
38
  ]
39
39
  },
40
40
  "dependencies": {
41
- "@auth0/auth0-spa-js": "~2.20.0",
42
- "@azure/msal-browser": "~5.11.0",
41
+ "@auth0/auth0-spa-js": "~2.21.1",
42
+ "@azure/msal-browser": "~5.13.0",
43
43
  "@blueprintjs/core": "^6.3.2",
44
44
  "@blueprintjs/datetime": "^6.0.6",
45
45
  "@codemirror/commands": "~6.10.3",
@@ -69,7 +69,7 @@
69
69
  "jwt-decode": "~4.0.0",
70
70
  "lodash": "~4.18.0",
71
71
  "lodash-inflection": "~1.5.0",
72
- "mobx": "~6.15.0",
72
+ "mobx": "~6.16.1",
73
73
  "mobx-react-lite": "~4.1.0",
74
74
  "moment": "~2.30.1",
75
75
  "numbro": "~2.5.0",
@@ -8,6 +8,7 @@ import {CallContextLike, HoistService, InitContext, XH} from '@xh/hoist/core';
8
8
  import {SECONDS} from '@xh/hoist/utils/datetime';
9
9
  import {debounced, deepFreeze, throwIf} from '@xh/hoist/utils/js';
10
10
  import {cloneDeep, forEach, isEmpty, isEqual} from 'lodash';
11
+ import {terminationSafePostJson} from './impl/Fetch';
11
12
 
12
13
  /**
13
14
  * Service to read and set user-specific preference values.
@@ -34,7 +35,13 @@ export class PrefService extends HoistService {
34
35
  private _updates = {};
35
36
 
36
37
  override async initAsync(ctx: InitContext) {
37
- window.addEventListener('beforeunload', () => this.pushPendingAsync());
38
+ // Flush on page teardown while the page is still alive.
39
+ this.addReaction({
40
+ track: () => XH.pageState,
41
+ run: () => {
42
+ if (!XH.pageIsVisible) this.pushPendingAsync();
43
+ }
44
+ });
38
45
  return this.loadPrefsAsync(ctx);
39
46
  }
40
47
 
@@ -112,27 +119,30 @@ export class PrefService extends HoistService {
112
119
  }
113
120
 
114
121
  /**
115
- * Push any pending buffered updates to persist newly set values to server.
116
- * Called automatically by this app on page unload to avoid dropping changes when e.g. a user
117
- * changes and option and then immediately hits a (browser) refresh.
122
+ * Push any pending buffered updates to persist newly set values to the server.
123
+ *
124
+ * Not typically called by applications. Called automatically by the framework after changes
125
+ * and when page is hidden/terminated.
118
126
  */
119
127
  async pushPendingAsync() {
120
128
  const updates = this._updates;
121
129
  if (isEmpty(updates)) return;
122
130
 
131
+ // Clear synchronously with the capture, so overlapping flushes cannot post twice.
132
+ this._updates = {};
133
+
123
134
  await this.runner()
124
135
  .span('set')
125
- .run(async ctx => {
126
- this._updates = {};
127
- await XH.postJson(
136
+ .run(ctx =>
137
+ terminationSafePostJson(
128
138
  {
129
139
  url: 'xh/setPrefs',
130
140
  body: updates,
131
141
  params: {clientUsername: XH.getUsername()}
132
142
  },
133
143
  ctx
134
- );
135
- });
144
+ )
145
+ );
136
146
  }
137
147
 
138
148
  //-------------------
@@ -140,7 +150,7 @@ export class PrefService extends HoistService {
140
150
  //-------------------
141
151
  @debounced(5 * SECONDS)
142
152
  private pushPendingBuffered() {
143
- this.pushPendingAsync();
153
+ void this.pushPendingAsync();
144
154
  }
145
155
 
146
156
  private async loadPrefsAsync(ctx: CallContextLike) {
@@ -4,10 +4,11 @@
4
4
  *
5
5
  * Copyright © 2026 Extremely Heavy Industries Inc.
6
6
  */
7
- import {HoistService, InitContext, PlainObject, XH, Span, FullSpanConfig} from '@xh/hoist/core';
7
+ import {HoistService, PlainObject, XH, Span, FullSpanConfig} from '@xh/hoist/core';
8
8
  import {SECONDS} from '@xh/hoist/utils/datetime';
9
9
  import {debounced, parseNameSource} from '@xh/hoist/utils/js';
10
- import {every, forEach, groupBy, isEmpty, isString, omitBy} from 'lodash';
10
+ import {every, forEach, groupBy, isEmpty, isString, omitBy, takeRight} from 'lodash';
11
+ import {terminationSafePostJson} from './impl/Fetch';
11
12
 
12
13
  /**
13
14
  * Client-side distributed tracing service for Hoist applications.
@@ -48,8 +49,14 @@ export class TraceService extends HoistService {
48
49
  //------------------
49
50
  // Initialization
50
51
  //------------------
51
- override async initAsync(ctx: InitContext) {
52
- window.addEventListener('beforeunload', () => this.pushPendingAsync());
52
+ override async initAsync() {
53
+ // Flush on page teardown while the page is still alive.
54
+ this.addReaction({
55
+ track: () => XH.pageState,
56
+ run: () => {
57
+ if (!XH.pageIsVisible) this.pushPendingInternalAsync();
58
+ }
59
+ });
53
60
  }
54
61
 
55
62
  //------------------
@@ -159,44 +166,11 @@ export class TraceService extends HoistService {
159
166
  }
160
167
 
161
168
  /**
162
- * Push all pending spans to the server.
163
- * Called on debounced interval and on page unload.
169
+ * Flush the queue of pending spans to the server.
170
+ * @internal - apps should generally allow this service to manage w/its internal debounce.
164
171
  */
165
172
  async pushPendingAsync() {
166
- const spans = this._pending;
167
- if (isEmpty(spans)) return;
168
-
169
- this._pending = [];
170
- try {
171
- await XH.postJson({
172
- url: 'xh/submitSpans',
173
- body: spans.map(s => s.toJSON()),
174
- params: {
175
- clientUsername: XH.getUsername()
176
- }
177
- });
178
- } catch (e) {
179
- if (isRetryableError(e)) {
180
- // Transient failure - re-queue the batch (ahead of newer spans) to retry on the
181
- // next flush, then bound the buffer in case the outage is prolonged.
182
- this._pending = [...spans, ...this._pending];
183
- this.enforceCap();
184
- this.logError('Failed to push spans - will retry on next flush', e);
185
- } else {
186
- // Permanent (client-side) rejection - drop the batch so it can't deadlock the
187
- // pipe (e.g. a session mismatch or oversized payload would fail forever).
188
- this.logError('Server rejected span batch - dropping', e);
189
- }
190
- }
191
- }
192
-
193
- /** Bound the pending buffer, silently dropping oldest spans (failed pushes are logged). */
194
- private enforceCap() {
195
- const {_pending} = this,
196
- {MAX_PENDING} = TraceService;
197
- if (_pending.length > MAX_PENDING) {
198
- _pending.splice(0, _pending.length - MAX_PENDING);
199
- }
173
+ return this.pushPendingInternalAsync();
200
174
  }
201
175
 
202
176
  /**
@@ -237,7 +211,45 @@ export class TraceService extends HoistService {
237
211
 
238
212
  @debounced(5 * SECONDS)
239
213
  private pushPendingBuffered() {
240
- void this.pushPendingAsync();
214
+ void this.pushPendingInternalAsync();
215
+ }
216
+
217
+ /** Flush all pending spans to the server. */
218
+ private async pushPendingInternalAsync() {
219
+ const spans = this._pending;
220
+ if (isEmpty(spans)) return;
221
+
222
+ // Clear synchronously with the capture, so overlapping flushes cannot post twice.
223
+ this._pending = [];
224
+ try {
225
+ await terminationSafePostJson({
226
+ url: 'xh/submitSpans',
227
+ body: spans.map(s => s.toJSON()),
228
+ params: {
229
+ clientUsername: XH.getUsername()
230
+ }
231
+ });
232
+ } catch (e) {
233
+ if (isRetryableError(e)) {
234
+ // Transient failure - re-queue the batch (ahead of newer spans) to retry on the
235
+ // next flush, then bound the buffer in case the outage is prolonged.
236
+ this._pending = [...spans, ...this._pending];
237
+ this.enforceCap();
238
+ this.logError('Failed to push spans - will retry on next flush', e);
239
+ } else {
240
+ // Permanent (client-side) rejection - drop the batch so it can't deadlock the
241
+ // pipe (e.g. a session mismatch or oversized payload would fail forever).
242
+ this.logError('Server rejected span batch - dropping', e);
243
+ }
244
+ }
245
+ }
246
+
247
+ /** Bound the pending buffer, silently dropping oldest spans (failed pushes are logged). */
248
+ private enforceCap() {
249
+ const {MAX_PENDING} = TraceService;
250
+ if (this._pending.length > MAX_PENDING) {
251
+ this._pending = takeRight(this._pending, MAX_PENDING);
252
+ }
241
253
  }
242
254
 
243
255
  /**
@@ -4,11 +4,12 @@
4
4
  *
5
5
  * Copyright © 2026 Extremely Heavy Industries Inc.
6
6
  */
7
- import {HoistService, InitContext, PlainObject, TrackOptions, XH} from '@xh/hoist/core';
7
+ import {HoistService, PlainObject, TrackOptions, XH} from '@xh/hoist/core';
8
8
  import {SECONDS} from '@xh/hoist/utils/datetime';
9
9
  import {isOmitted} from '@xh/hoist/utils/impl';
10
10
  import {debounced, stripTags, withDefault} from '@xh/hoist/utils/js';
11
11
  import {isEmpty, isNil, isString} from 'lodash';
12
+ import {terminationSafePostJson} from './impl/Fetch';
12
13
 
13
14
  /**
14
15
  * Primary service for tracking any activity that an application's admins want to track.
@@ -23,8 +24,14 @@ export class TrackService extends HoistService {
23
24
  private oncePerSessionSent = new Map();
24
25
  private pending: PlainObject[] = [];
25
26
 
26
- override async initAsync(ctx: InitContext) {
27
- window.addEventListener('beforeunload', () => this.pushPendingAsync());
27
+ override async initAsync() {
28
+ // Flush on page teardown while the page is still alive
29
+ this.addReaction({
30
+ track: () => XH.pageState,
31
+ run: () => {
32
+ if (!XH.pageIsVisible) this.pushPendingAsync();
33
+ }
34
+ });
28
35
  }
29
36
 
30
37
  get conf(): ActivityTrackingConfig {
@@ -94,25 +101,29 @@ export class TrackService extends HoistService {
94
101
 
95
102
  /**
96
103
  * Flush the queue of pending activity tracking messages to the server.
97
- * @internal - apps should generally allow this service to manage w/its internal debounce.
104
+ *
105
+ * Not typically called by applications. Called automatically by the framework via a
106
+ * debounce and when the page is hidden/terminated.
98
107
  */
99
108
  async pushPendingAsync() {
100
109
  const {pending} = this;
101
110
  if (isEmpty(pending)) return;
102
111
 
112
+ // Clear synchronously with the capture, so overlapping flushes cannot post twice.
113
+ this.pending = [];
114
+
103
115
  await this.runner()
104
116
  .span('push')
105
- .run(async ctx => {
106
- this.pending = [];
107
- await XH.postJson(
117
+ .run(ctx =>
118
+ terminationSafePostJson(
108
119
  {
109
120
  url: 'xh/track',
110
121
  body: {entries: pending},
111
122
  params: {clientUsername: XH.getUsername()}
112
123
  },
113
124
  ctx
114
- );
115
- });
125
+ )
126
+ );
116
127
  }
117
128
 
118
129
  //------------------
@@ -120,7 +131,7 @@ export class TrackService extends HoistService {
120
131
  //------------------
121
132
  @debounced(10 * SECONDS)
122
133
  private pushPendingBuffered() {
123
- this.pushPendingAsync();
134
+ void this.pushPendingAsync();
124
135
  }
125
136
 
126
137
  private toServerJson(options: TrackOptions): PlainObject {
@@ -0,0 +1,33 @@
1
+ /*
2
+ * This file belongs to Hoist, an application development toolkit
3
+ * developed by Extremely Heavy Industries (www.xh.io | info@xh.io)
4
+ *
5
+ * Copyright © 2026 Extremely Heavy Industries Inc.
6
+ */
7
+ import {CallContextLike, XH} from '@xh/hoist/core';
8
+ import type {FetchOptions} from '../FetchService';
9
+
10
+ /**
11
+ * Post a JSON body, using `fetch({keepalive: true})` when the page is no longer visible so the
12
+ * request can survive teardown (e.g. a flush as `XH.pageState` goes `hidden`/`frozen`/`terminated`).
13
+ *
14
+ * Keepalive bodies share a single browser-wide 64KB budget; if exceeded the request is never sent
15
+ * and we retry once uncapped (without keepalive), which still completes while the page is alive (the
16
+ * common `hidden` case). Real server errors are re-thrown, not re-posted.
17
+ *
18
+ * @internal
19
+ */
20
+ export async function terminationSafePostJson(
21
+ opts: FetchOptions,
22
+ ctx?: CallContextLike
23
+ ): Promise<any> {
24
+ if (XH.pageIsVisible) return XH.postJson(opts, ctx);
25
+
26
+ try {
27
+ return await XH.postJson({...opts, fetchOpts: {...opts.fetchOpts, keepalive: true}}, ctx);
28
+ } catch (e: any) {
29
+ // Retry uncapped if keepalive was never sent (over-budget); re-throw real server errors.
30
+ if (e.isServerUnavailable) return XH.postJson(opts, ctx);
31
+ throw e;
32
+ }
33
+ }
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * Copyright © 2026 Extremely Heavy Industries Inc.
6
6
  */
7
- import {XH} from '@xh/hoist/core';
7
+ import {Exception} from '@xh/hoist/exception';
8
8
 
9
9
  /**
10
10
  * Copy the given text to the system clipboard.
@@ -53,5 +53,5 @@ function copyViaExecCommand(text: string): void {
53
53
  selection.removeAllRanges();
54
54
  document.body.removeChild(span);
55
55
  }
56
- if (!success) throw XH.exception('Clipboard copy not allowed');
56
+ if (!success) throw Exception.create('Clipboard copy not allowed');
57
57
  }
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * Copyright © 2026 Extremely Heavy Industries Inc.
6
6
  */
7
- import {HoistProps} from '@xh/hoist/core';
7
+ import type {HoistProps} from '@xh/hoist/core';
8
8
  import {isString} from 'lodash';
9
9
 
10
10
  /**
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * Copyright © 2026 Extremely Heavy Industries Inc.
6
6
  */
7
- import {LayoutProps, ResolvedLayoutProps, PlainObject} from '@xh/hoist/core';
7
+ import type {LayoutProps, ResolvedLayoutProps, PlainObject} from '@xh/hoist/core';
8
8
  import {forOwn, isEmpty, isNumber, isString, isNil, omit, pick} from 'lodash';
9
9
 
10
10
  const XH_PAD_VAR = 'var(--xh-pad-px)';
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * Copyright © 2026 Extremely Heavy Industries Inc.
6
6
  */
7
- import {Content} from '@xh/hoist/core';
7
+ import type {Content} from '@xh/hoist/core';
8
8
  import {ReactElement, cloneElement, createElement, isValidElement} from 'react';
9
9
  import {isFunction, isNil} from 'lodash';
10
10
  import {renderToStaticMarkup as reactRenderToStaticMarkup} from 'react-dom/server';