@xh/hoist 86.0.0 → 86.1.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.
Files changed (75) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/admin/tabs/userData/roles/RoleModel.ts +1 -19
  3. package/appcontainer/RouterModel.ts +2 -0
  4. package/appcontainer/ThemeModel.ts +41 -0
  5. package/appcontainer/login/LoginPanelModel.ts +6 -2
  6. package/build/types/appcontainer/RouterModel.d.ts +1 -0
  7. package/build/types/appcontainer/ThemeModel.d.ts +9 -0
  8. package/build/types/cmp/input/SegmentedControlOption.d.ts +44 -0
  9. package/build/types/cmp/input/index.d.ts +1 -0
  10. package/build/types/core/AppSpec.d.ts +8 -2
  11. package/build/types/core/HoistAuthModel.d.ts +6 -3
  12. package/build/types/data/filter/BaseFilterFieldSpec.d.ts +7 -1
  13. package/build/types/data/filter/FieldFilter.d.ts +2 -0
  14. package/build/types/desktop/cmp/filechooser/FileChooserModel.d.ts +1 -1
  15. package/build/types/desktop/cmp/input/SegmentedControl.d.ts +1 -41
  16. package/build/types/icon/Icon.d.ts +1 -0
  17. package/build/types/mobile/cmp/appOption/SizingModeAppOption.d.ts +3 -3
  18. package/build/types/mobile/cmp/appOption/ThemeAppOption.d.ts +3 -3
  19. package/build/types/mobile/cmp/input/SegmentedControl.d.ts +47 -0
  20. package/build/types/mobile/cmp/input/SwitchInput.d.ts +1 -1
  21. package/build/types/mobile/cmp/input/TextInput.d.ts +3 -0
  22. package/build/types/mobile/cmp/input/index.d.ts +1 -0
  23. package/build/types/svc/JsonBlobService.d.ts +7 -7
  24. package/build/types/svc/PrefService.d.ts +4 -3
  25. package/build/types/svc/TraceService.d.ts +8 -6
  26. package/build/types/svc/TrackService.d.ts +5 -3
  27. package/build/types/svc/impl/Fetch.d.ts +13 -0
  28. package/build/types/utils/js/TestUtils.d.ts +1 -1
  29. package/build/types/utils/react/LayoutPropUtils.d.ts +1 -1
  30. package/build/types/utils/react/ReactUtils.d.ts +1 -1
  31. package/cmp/error/ErrorMessage.scss +2 -0
  32. package/cmp/grid/filter/GridFilterFieldSpec.ts +5 -2
  33. package/cmp/grid/impl/GridHScrollbar.ts +10 -3
  34. package/cmp/input/SegmentedControlOption.ts +61 -0
  35. package/cmp/input/index.ts +1 -0
  36. package/core/AppSpec.ts +9 -1
  37. package/core/HoistAuthModel.ts +13 -6
  38. package/data/Field.ts +3 -1
  39. package/data/filter/BaseFilterFieldSpec.ts +16 -2
  40. package/data/filter/FieldFilter.ts +90 -21
  41. package/desktop/appcontainer/LoginPanel.ts +5 -3
  42. package/desktop/cmp/filechooser/FileChooserModel.ts +1 -1
  43. package/desktop/cmp/filter/FilterChooser.scss +0 -5
  44. package/desktop/cmp/grid/impl/filter/headerfilter/HeaderFilterModel.ts +9 -4
  45. package/desktop/cmp/grid/impl/filter/headerfilter/custom/CustomRowModel.ts +2 -2
  46. package/desktop/cmp/input/SegmentedControl.ts +8 -51
  47. package/desktop/cmp/tab/Tabs.scss +6 -2
  48. package/desktop/cmp/tab/dynamic/DynamicTabSwitcher.ts +7 -3
  49. package/docs/error-handling.md +9 -0
  50. package/icon/Icon.ts +3 -0
  51. package/icon/index.ts +8 -0
  52. package/mobile/appcontainer/LoginPanel.scss +53 -11
  53. package/mobile/appcontainer/LoginPanel.ts +69 -44
  54. package/mobile/cmp/appOption/SizingModeAppOption.ts +5 -16
  55. package/mobile/cmp/appOption/ThemeAppOption.ts +8 -25
  56. package/mobile/cmp/error/impl/ErrorMessage.ts +4 -8
  57. package/mobile/cmp/input/SegmentedControl.scss +126 -0
  58. package/mobile/cmp/input/SegmentedControl.ts +210 -0
  59. package/mobile/cmp/input/SwitchInput.ts +1 -1
  60. package/mobile/cmp/input/TextInput.scss +7 -0
  61. package/mobile/cmp/input/TextInput.ts +11 -1
  62. package/mobile/cmp/input/index.ts +1 -0
  63. package/mobile/cmp/tab/impl/Tabs.scss +8 -0
  64. package/mobile/cmp/toolbar/Toolbar.scss +8 -0
  65. package/package.json +4 -4
  66. package/styles/vars.scss +6 -1
  67. package/svc/JsonBlobService.ts +21 -20
  68. package/svc/PrefService.ts +20 -10
  69. package/svc/TraceService.ts +53 -41
  70. package/svc/TrackService.ts +21 -10
  71. package/svc/impl/Fetch.ts +33 -0
  72. package/utils/js/ClipboardUtils.ts +2 -2
  73. package/utils/js/TestUtils.ts +1 -1
  74. package/utils/react/LayoutPropUtils.ts +1 -1
  75. package/utils/react/ReactUtils.ts +1 -1
@@ -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';