@xh/hoist 89.0.0 → 89.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
@@ -12,9 +12,26 @@
12
12
  3. Plain ASCII punctuation only. Use " - " for in-sentence breaks, never an em dash.
13
13
  -->
14
14
 
15
+ ## 89.0.1 - 2026-10-14
16
+
17
+ ### 🐞 Bug Fixes
18
+
19
+ * `GridModel.setColumnState()` now drops repeated `colId` entries from saved column state, keeping
20
+ the first. Saved state with a repeated column made the v87 column chooser silently error and break.
21
+ * Fixed desktop `DashCanvas` failing to render in v89.0.0.
22
+ * Fixed `XH.message()`, `XH.confirm()`, and `XH.prompt()` resolving to `null` when a route change
23
+ or other forced close shut the dialog. Callers that tested `!== false` ran the confirmed action.
24
+ A dialog closed without a choice now resolves to `false`, the same as a cancel. A dismiss with
25
+ `cancelOnDismiss: false` also resolves to `false` - the flag now only skips `onCancel`.
26
+
27
+ ### ⚙️ Technical
28
+
29
+ * The unit test kit now stubs `ResizeObserver`, which jsdom lacks, so app specs can render
30
+ components that track their own size.
31
+
15
32
  ## 89.0.0 - 2026-10-13
16
33
 
17
- ### 💥 Breaking Changes (upgrade difficulty: 🟢 LOW - filter specs, app option presets, icon listing, grid defaults, badge spacing, column library config)
34
+ ### 💥 Breaking Changes (upgrade difficulty: 🟢 LOW - filter specs, app option presets, icon listing, grid defaults, grid options, badge spacing, column library config)
18
35
 
19
36
  See [`docs/upgrade-notes/v89-upgrade-notes.md`](docs/upgrade-notes/v89-upgrade-notes.md) for
20
37
  detailed, step-by-step upgrade instructions with before/after code examples.
@@ -35,6 +52,9 @@ detailed, step-by-step upgrade instructions with before/after code examples.
35
52
  `span('Users ', badge(n))`, or with a `gap` on an enclosing `hbox`.
36
53
  * Removed `ColLibraryConfig.autoExpandOnFilter`. The Column Library now expands every group with a
37
54
  match while a search is active. Remove the key from any `columnLibrary` config.
55
+ * `Grid` now ignores `agOptions.groupDefaultExpanded`. Set `GridModel.expandLevel` instead.
56
+ * Removed `GridExperimentalFlags.disableScrollOptimization`. The post-load row height pass it
57
+ disabled now runs only for grids whose rows vary in height. Remove the key from grid configs.
38
58
 
39
59
  ### 🎁 New Features
40
60
 
@@ -123,7 +143,8 @@ detailed, step-by-step upgrade instructions with before/after code examples.
123
143
  * Fixed `@sharePendingPromise` running a method twice when it threw synchronously.
124
144
  * Fixed a cancelled `Timer` keeping its heartbeat alive, and a `Timer` with `delay: true` and a
125
145
  disabled interval scheduling a negative timeout.
126
- * Fixed a `PUT` with `params` labeling its form body `text/plain`, so servers ignored the params.
146
+ * `FetchService` now sends every `PUT` without a `Content-Type` as form-encoded, as for `POST`.
147
+ A `PUT` with `params` sent `text/plain`, so servers ignored the params.
127
148
  * Fixed errors reported by `ExceptionHandler` omitting the type and number of a failed load.
128
149
  Arrays in these reports, such as the stack trace, are also no longer sent as objects.
129
150
  * Fixed `XH.reloadApp()` re-encoding spaces in existing query params as `+`, and leaving its
@@ -155,8 +176,6 @@ detailed, step-by-step upgrade instructions with before/after code examples.
155
176
  * `AgGrid` now publishes its row and header heights to ag-Grid as `--ag-row-height` and
156
177
  `--ag-header-height`. `Grid` and `AgGrid` pass ag-Grid a `getRowHeight` function only when row
157
178
  heights vary, so uniform grids skip per-row height calls and the post-load row height pass.
158
- * Removed `GridExperimentalFlags.disableScrollOptimization`. The post-load row height pass now
159
- runs only for grids whose rows vary in height, where it prevents rows shifting on scroll.
160
179
  * `Column` tooltips no longer mount for rows without a record, or for editable cells with no
161
180
  validation results, and the tooltip component is a plain function component.
162
181
  * `Grid` now passes AG Grid only the pinned rows that changed. A summary row update redraws the
@@ -178,7 +197,8 @@ detailed, step-by-step upgrade instructions with before/after code examples.
178
197
  spurious one-time re-render.
179
198
  * `Store` now patches small changes by default (`experimental.maxPatchRatio` of 0.1). Records added
180
199
  by a change append rather than taking their source position. This may affect the sort ordering
181
- of unsorted grids when data changes.
200
+ of unsorted grids when data changes. Filters that read state outside the record must call
201
+ `Store.refreshFilter()` when that state changes.
182
202
  * `Field` now skips DOMPurify for strings with no `<` when `enableXssProtection` is set. Such
183
203
  strings hold no markup, so large loads skip one DOMPurify call per string value.
184
204
  * `Field` now parses every unparseable `date` value to one shared Invalid Date, preventing an
@@ -38,7 +38,7 @@ export class MessageModel extends HoistModel {
38
38
  dismissable;
39
39
  cancelOnDismiss;
40
40
 
41
- // Promise settled when the message closes - with the user's choice, or null if forced close
41
+ // Promise settled when the message closes - with the user's choice, or false if forced closed
42
42
  result;
43
43
  _resolver;
44
44
 
@@ -152,7 +152,8 @@ export class MessageModel extends HoistModel {
152
152
  const {formModel} = this;
153
153
  if (formModel) {
154
154
  await formModel.validateAsync();
155
- if (!formModel.isValid) return;
155
+ // Message may have been force closed while validating - its result is already false.
156
+ if (!this.isOpen || !formModel.isValid) return;
156
157
  const data = formModel.getData();
157
158
  if (formModel.getField('value')) {
158
159
  resolvedVal = data.value;
@@ -169,22 +170,22 @@ export class MessageModel extends HoistModel {
169
170
  @action
170
171
  doCancel() {
171
172
  this.onCancel?.();
172
- this.close(false);
173
+ this.close();
173
174
  }
174
175
 
175
176
  @action
176
177
  doEscape() {
177
178
  if (!this.dismissable) return;
178
- if (this.cancelOnDismiss) {
179
- this.doCancel();
180
- return;
181
- }
182
- this.close();
179
+ this.cancelOnDismiss ? this.doCancel() : this.close();
183
180
  }
184
181
 
185
- /** Close the message, settling `result` with the given value. */
182
+ /**
183
+ * Close the message, settling `result` with the given value. Defaults to false, so a close
184
+ * the user did not choose reads as a cancel to every caller - see `XH.message()`.
185
+ */
186
186
  @action
187
- close(result: unknown = null) {
187
+ close(result: unknown = false) {
188
+ if (!this.isOpen) return;
188
189
  this._resolver(result);
189
190
  this.isOpen = false;
190
191
  }
@@ -84,6 +84,9 @@ methods return a `Promise` that resolves when the user responds, making them ide
84
84
  The generic modal dialog API — all other message methods delegate to it. Returns
85
85
  `Promise<boolean>` that resolves to `true` on confirm, `false` on cancel.
86
86
 
87
+ A message that the user dismisses, or that closes without a choice, also resolves to `false`. A
88
+ route change, a later message with the same `messageKey`, or a destroy closes a message this way.
89
+
87
90
  ```typescript
88
91
  const result = await XH.message({
89
92
  title: 'Export Complete',
@@ -153,7 +156,8 @@ const confirmed = await XH.confirm({
153
156
  ### `XH.prompt(config)`
154
157
 
155
158
  An input collection dialog with a default `TextInput`, "OK" and "Cancel" buttons. Returns
156
- `Promise<value | false>` — the input value on confirm, `false` on cancel.
159
+ `Promise<value | false>` — the input value on confirm, `false` on cancel. If an empty input is a
160
+ valid answer, test the result with `!== false`, because `null` and `''` are confirmed values.
157
161
 
158
162
  The default TextInput comes pre-configured with `autoFocus`, `selectOnFocus`, and
159
163
  enter-to-confirm (desktop only). Custom inputs must set their own props.
@@ -222,7 +226,7 @@ XH.alert({
222
226
  | `onConfirm` | `() => void` | Callback fired on confirm (in addition to Promise resolution) |
223
227
  | `onCancel` | `() => void` | Callback fired on cancel (in addition to Promise resolution) |
224
228
  | `dismissable` | `boolean` | Whether Escape or clicking outside closes the dialog. Defaults to `true` when cancel button is present |
225
- | `cancelOnDismiss` | `boolean` | Whether dismissing executes the cancel action (vs resolving to `null`). Default `true` |
229
+ | `cancelOnDismiss` | `boolean` | Whether dismissing calls `onCancel`. The result is `false` either way. Default `true` |
226
230
 
227
231
  **MessageSpecInput** (for `XH.prompt()` config):
228
232
 
@@ -41,7 +41,10 @@ export declare class MessageModel extends HoistModel {
41
41
  doConfirmAsync(): Promise<void>;
42
42
  doCancel(): void;
43
43
  doEscape(): void;
44
- /** Close the message, settling `result` with the given value. */
44
+ /**
45
+ * Close the message, settling `result` with the given value. Defaults to false, so a close
46
+ * the user did not choose reads as a cancel to every caller - see `XH.message()`.
47
+ */
45
48
  close(result?: unknown): void;
46
49
  destroy(): void;
47
50
  private parseButtonProps;
@@ -335,10 +335,10 @@ export declare class XHApi {
335
335
  * this method will resolve immediately to their previously saved response, without showing
336
336
  * a dialog. This also applies to the `alert`, `confirm`, and `prompt` variants below.
337
337
  *
338
- * @returns true if user confirms, false if user cancels. If an input is provided, the
339
- * returned Promise will resolve to the input value if user confirms, false if user cancels.
340
- * Resolves to null if the message is closed without a choice - e.g. by a route change or
341
- * when replaced by a later message with the same `messageKey`.
338
+ * @returns true if the user confirms, or the input value if an input is provided. Resolves
339
+ * false for any other outcome - a cancel, a dismiss, or a close without a choice, e.g. by a
340
+ * route change or a later message with the same `messageKey`. With an input, test the result
341
+ * with `!== false`, as null and an empty string are valid confirmed values.
342
342
  */
343
343
  message<T = unknown>(config: MessageSpec): Promise<T | boolean>;
344
344
  /**
@@ -348,7 +348,7 @@ export declare class XHApi {
348
348
  alert(config: MessageSpec): Promise<boolean>;
349
349
  /**
350
350
  * Show a modal 'confirm' dialog with message and default 'OK'/'Cancel' buttons.
351
- * @returns true if user confirms, false if user cancels.
351
+ * @returns true if user confirms, false otherwise. See {@link message} for details.
352
352
  */
353
353
  confirm(config: MessageSpec): Promise<boolean>;
354
354
  /**
@@ -360,7 +360,8 @@ export declare class XHApi {
360
360
  * 3. onKeyDown handler to confirm on <enter> (same as clicking 'OK') (desktop only)
361
361
  * Applications may also provide a custom HoistInput, in which all props must be set.
362
362
  *
363
- * @returns value of input if user confirms, false if user cancels.
363
+ * @returns value of input if user confirms, false otherwise. See {@link message} for details.
364
+ * Test the result with `!== false`, as null and an empty string are valid confirmed values.
364
365
  */
365
366
  prompt<T = unknown>(config: MessageSpec): Promise<T | false>;
366
367
  /**
package/cmp/grid/Grid.ts CHANGED
@@ -242,7 +242,7 @@ export class GridLocalModel extends HoistModel {
242
242
  this.agOptions = merge(this.createDefaultAgOptions(), this.componentProps.agOptions || {});
243
243
  warnIf(
244
244
  this.componentProps.agOptions?.groupDefaultExpanded != null,
245
- 'agOptions.groupDefaultExpanded is not supported. Use GridModel.expandLevel instead.'
245
+ 'agOptions.groupDefaultExpanded is ignored. Set GridModel.expandLevel instead.'
246
246
  );
247
247
  this.rowHeightSupport = new RowHeightSupport(this);
248
248
  this.transactionMgr = new GridTransactionManager(this.model);
@@ -115,7 +115,8 @@ import {
115
115
  omit,
116
116
  pick,
117
117
  pull,
118
- take
118
+ take,
119
+ uniqBy
119
120
  } from 'lodash';
120
121
  import {createRef, ReactNode, RefObject} from 'react';
121
122
  import {GridAutosizeOptions} from './GridAutosizeOptions';
@@ -2024,8 +2025,12 @@ export class GridModel extends HoistModel {
2024
2025
  const gridCols = this.getLeafColumns();
2025
2026
 
2026
2027
  // REMOVE any state columns that are no longer found in the grid. These were likely saved
2027
- // under a prior release of the app and have since been removed from the code.
2028
- let ret = columnState.filter(({colId}) => this.getColumn(colId));
2028
+ // under a prior release of the app and have since been removed from the code. Also
2029
+ // REMOVE any repeated columns, keeping the first.
2030
+ let ret = uniqBy<any>(
2031
+ columnState.filter(({colId}) => this.getColumn(colId)),
2032
+ 'colId'
2033
+ );
2029
2034
 
2030
2035
  // ADD any grid columns that are not found in state. These are newly added to the code.
2031
2036
  // Insert these columns in position based on the index at which they are defined.
package/core/XH.ts CHANGED
@@ -671,10 +671,10 @@ export class XHApi {
671
671
  * this method will resolve immediately to their previously saved response, without showing
672
672
  * a dialog. This also applies to the `alert`, `confirm`, and `prompt` variants below.
673
673
  *
674
- * @returns true if user confirms, false if user cancels. If an input is provided, the
675
- * returned Promise will resolve to the input value if user confirms, false if user cancels.
676
- * Resolves to null if the message is closed without a choice - e.g. by a route change or
677
- * when replaced by a later message with the same `messageKey`.
674
+ * @returns true if the user confirms, or the input value if an input is provided. Resolves
675
+ * false for any other outcome - a cancel, a dismiss, or a close without a choice, e.g. by a
676
+ * route change or a later message with the same `messageKey`. With an input, test the result
677
+ * with `!== false`, as null and an empty string are valid confirmed values.
678
678
  */
679
679
  message<T = unknown>(config: MessageSpec): Promise<T | boolean> {
680
680
  return this.acm.messageSourceModel.message(config);
@@ -690,7 +690,7 @@ export class XHApi {
690
690
 
691
691
  /**
692
692
  * Show a modal 'confirm' dialog with message and default 'OK'/'Cancel' buttons.
693
- * @returns true if user confirms, false if user cancels.
693
+ * @returns true if user confirms, false otherwise. See {@link message} for details.
694
694
  */
695
695
  confirm(config: MessageSpec): Promise<boolean> {
696
696
  return this.acm.messageSourceModel.confirm(config);
@@ -705,7 +705,8 @@ export class XHApi {
705
705
  * 3. onKeyDown handler to confirm on <enter> (same as clicking 'OK') (desktop only)
706
706
  * Applications may also provide a custom HoistInput, in which all props must be set.
707
707
  *
708
- * @returns value of input if user confirms, false if user cancels.
708
+ * @returns value of input if user confirms, false otherwise. See {@link message} for details.
709
+ * Test the result with `!== false`, as null and an empty string are valid confirmed values.
709
710
  */
710
711
  prompt<T = unknown>(config: MessageSpec): Promise<T | false> {
711
712
  return this.acm.messageSourceModel.prompt(config);
package/data/README.md CHANGED
@@ -270,8 +270,9 @@ store.count; // Filtered record count
270
270
  store.allCount; // Total record count
271
271
  ```
272
272
 
273
- Store re-filters automatically whenever record data changes. Call `refreshFilter()` only when the
274
- state behind a `FunctionFilter` changes without any change to the records themselves.
273
+ Store re-filters automatically whenever record data changes, but a patched change (the default for
274
+ small changes) re-tests only the records it touches. Call `refreshFilter()` when the state behind a
275
+ `FunctionFilter` changes without any change to the records themselves.
275
276
 
276
277
  ### Observable Properties
277
278
 
@@ -5,11 +5,11 @@
5
5
  * Copyright © 2026 Extremely Heavy Industries Inc.
6
6
  */
7
7
  import {isEmpty} from 'lodash';
8
- import {ReactElement} from 'react';
9
8
  import {box} from '@xh/hoist/cmp/layout';
10
- import {hoistCmp, uses} from '@xh/hoist/core';
11
- import {ContextMenu} from '@xh/hoist/desktop/cmp/contextmenu';
9
+ import {hoistCmp, type MenuItemLike, uses} from '@xh/hoist/core';
10
+ import {contextMenu} from '@xh/hoist/desktop/cmp/contextmenu';
12
11
  import {createViewMenuItems} from '@xh/hoist/desktop/cmp/dash/canvas/impl/utils';
12
+ import {parseMenuItems} from '@xh/hoist/desktop/cmp/menu/impl/MenuItems';
13
13
  import {elementFromContent, useOnResize} from '@xh/hoist/utils/react';
14
14
  import {Icon} from '@xh/hoist/icon';
15
15
  import {popover, Position} from '@xh/hoist/kit/blueprint';
@@ -72,55 +72,52 @@ const headerMenu = hoistCmp.factory<DashCanvasViewModel>(({model}) => {
72
72
  viewId: id,
73
73
  replaceExisting: true
74
74
  }),
75
- content = ContextMenu({
76
- menuItems: [
77
- {
78
- text: 'Add',
79
- icon: Icon.add(),
80
- items: addMenuItems,
81
- hidden: contentLocked || isEmpty(addMenuItems)
82
- },
83
- {
84
- text: 'Remove',
85
- icon: Icon.cross(),
86
- hidden: !viewSpec.allowRemove || contentLocked,
87
- actionFn: () => containerModel.removeView(id)
88
- },
89
- {
90
- text: 'Rename',
91
- icon: Icon.edit(),
92
- hidden: !viewSpec.allowRename || renameLocked,
93
- actionFn: () => containerModel.renameView(id)
94
- },
95
- {
96
- text: 'Replace',
97
- icon: Icon.transaction(),
98
- items: replaceMenuItems,
99
- hidden: !viewSpec.allowRemove || contentLocked || isEmpty(replaceMenuItems)
100
- },
101
- {
102
- text: 'Duplicate',
103
- icon: Icon.copy(),
104
- hidden: !allowDuplicate || contentLocked || viewSpec.unique,
105
- actionFn: () =>
106
- containerModel
107
- .addViewInternal(viewSpec.id, {
108
- layout: getDuplicateLayout(positionParams, model),
109
- state: viewState,
110
- title
111
- })
112
- .ensureVisible()
113
- },
114
- '-',
115
- ...(model.extraMenuItems ?? []),
116
- '-',
117
- ...(containerModel.extraMenuItems ?? [])
118
- ]
119
- }) as ReactElement;
75
+ menuItems: MenuItemLike[] = [
76
+ {
77
+ text: 'Add',
78
+ icon: Icon.add(),
79
+ items: addMenuItems,
80
+ hidden: contentLocked || isEmpty(addMenuItems)
81
+ },
82
+ {
83
+ text: 'Remove',
84
+ icon: Icon.cross(),
85
+ hidden: !viewSpec.allowRemove || contentLocked,
86
+ actionFn: () => containerModel.removeView(id)
87
+ },
88
+ {
89
+ text: 'Rename',
90
+ icon: Icon.edit(),
91
+ hidden: !viewSpec.allowRename || renameLocked,
92
+ actionFn: () => containerModel.renameView(id)
93
+ },
94
+ {
95
+ text: 'Replace',
96
+ icon: Icon.transaction(),
97
+ items: replaceMenuItems,
98
+ hidden: !viewSpec.allowRemove || contentLocked || isEmpty(replaceMenuItems)
99
+ },
100
+ {
101
+ text: 'Duplicate',
102
+ icon: Icon.copy(),
103
+ hidden: !allowDuplicate || contentLocked || viewSpec.unique,
104
+ actionFn: () =>
105
+ containerModel
106
+ .addViewInternal(viewSpec.id, {
107
+ layout: getDuplicateLayout(positionParams, model),
108
+ state: viewState,
109
+ title
110
+ })
111
+ .ensureVisible()
112
+ },
113
+ '-',
114
+ ...(model.extraMenuItems ?? []),
115
+ '-',
116
+ ...(containerModel.extraMenuItems ?? [])
117
+ ];
120
118
 
121
- // Workaround using React functional component to check if ContextMenu renders null
122
- // TODO - build a popover wrapper that null-checks content instead of this workaround
123
- if (!content) return null;
119
+ // Skip the button if every item is hidden.
120
+ if (isEmpty(parseMenuItems(menuItems))) return null;
124
121
 
125
122
  return popover({
126
123
  position: Position.BOTTOM,
@@ -128,7 +125,7 @@ const headerMenu = hoistCmp.factory<DashCanvasViewModel>(({model}) => {
128
125
  item: button({
129
126
  icon: Icon.ellipsisVertical()
130
127
  }),
131
- content
128
+ content: contextMenu({menuItems})
132
129
  });
133
130
  });
134
131
 
@@ -20,7 +20,8 @@ the move to TypeScript 7 is strongly recommended.
20
20
  - **Icons added via `library.add()` (recommended)** - Register them with `Icon.register()` so
21
21
  `IconPicker` offers them - see Step 8.
22
22
  - **Grid rendering defaults (review)** - Column virtualisation is now on, and columns without a
23
- `renderer` render plain text with no wrapper span - see Step 9.
23
+ `renderer` render plain text with no wrapper span. React element values in those columns need a
24
+ `renderer` - see Step 9.
24
25
  - **Badge spacing (required if used)** - `Badge` no longer has a 5px left margin. Badges that
25
26
  follow text need a space or a container `gap` - see Step 10.
26
27
  - **Column Library config (required if used)** - `ColLibraryConfig.autoExpandOnFilter` is gone.
@@ -28,6 +29,10 @@ the move to TypeScript 7 is strongly recommended.
28
29
  - **App option presets (required if used)** - Desktop `themeAppOption()` and
29
30
  `sizingModeAppOption()` now render a `RadioCardInput`. Apps passing `SegmentedControl` props via
30
31
  `inputProps` should add `previewCards: false` - see Step 12.
32
+ - **`agOptions.groupDefaultExpanded` (required if used)** - `Grid` now ignores it. Set
33
+ `GridModel.expandLevel` instead - see Step 13.
34
+ - **`disableScrollOptimization` flag (required if used)** - `GridExperimentalFlags` no longer has
35
+ this flag. An app that sets it fails to compile - see Step 14.
31
36
 
32
37
  Apps can also stay on TypeScript 5.9. Hoist v89 type-checks under 5.9, 6, and 7, so the TypeScript
33
38
  move does not have to ship with the Hoist bump. We still recommend that you take both together.
@@ -440,6 +445,44 @@ After:
440
445
  }
441
446
  ```
442
447
 
448
+ **Element values need a renderer.** ag-Grid writes a plain cell's value as text, so a React
449
+ element value now shows as `[object Object]`. The usual source is a `getValueFn` that calls a Hoist
450
+ formatter. `fmtNumber` and its variants return an element for options such as `label`, `ledger`,
451
+ `colorSpec` and `tooltip`. Have the `getValueFn` return the raw value, and format it in a
452
+ `renderer`. Sorting, filtering and export also read the raw value.
453
+
454
+ **Find affected columns:**
455
+
456
+ ```bash
457
+ grep -rn "getValueFn" client-app/src/
458
+ ```
459
+
460
+ Before:
461
+
462
+ ```typescript
463
+ {
464
+ field: 'elapsed',
465
+ getValueFn: ({record}) => {
466
+ const {startTime, endTime} = record.data;
467
+ if (!startTime || !endTime) return '-';
468
+ return fmtNumber((endTime - startTime) / 1000, {precision: 2, label: 's'});
469
+ }
470
+ }
471
+ ```
472
+
473
+ After:
474
+
475
+ ```typescript
476
+ {
477
+ field: 'elapsed',
478
+ getValueFn: ({record}) => {
479
+ const {startTime, endTime} = record.data;
480
+ return startTime && endTime ? (endTime - startTime) / 1000 : null;
481
+ },
482
+ renderer: numberRenderer({precision: 2, label: 's', nullDisplay: '-'})
483
+ }
484
+ ```
485
+
443
486
  ### 10. Space Badges from Preceding Text
444
487
 
445
488
  `Badge` now renders inline with no outer margin, like a badge in most component libraries. The old
@@ -520,6 +563,47 @@ After:
520
563
  themeAppOption({previewCards: false, inputProps: {outlined: true}})
521
564
  ```
522
565
 
566
+ ### 13. Replace `agOptions.groupDefaultExpanded` with `expandLevel`
567
+
568
+ `Grid` now opens group rows with ag-Grid's `isGroupOpenByDefault` callback, driven by
569
+ `GridModel.expandLevel`. ag-Grid checks that callback before `groupDefaultExpanded`, so a grid
570
+ that sets `groupDefaultExpanded` in `agOptions` now ignores it. Such a grid logs a console warning
571
+ and opens only one level, the default `expandLevel` for grouped grids.
572
+
573
+ **Find affected code:**
574
+
575
+ ```bash
576
+ grep -rn "groupDefaultExpanded" client-app/src/
577
+ ```
578
+
579
+ Move the value to `expandLevel` on the `GridModel`.
580
+
581
+ Before:
582
+
583
+ ```typescript
584
+ grid({agOptions: {groupDefaultExpanded: 2}})
585
+ ```
586
+
587
+ After:
588
+
589
+ ```typescript
590
+ new GridModel({groupBy: ['region', 'desk'], expandLevel: 2, ...})
591
+ ```
592
+
593
+ ### 14. Remove `GridExperimentalFlags.disableScrollOptimization`
594
+
595
+ The post-load row height pass this flag disabled now runs only for grids whose rows vary in height,
596
+ so the flag is gone. An app that still sets it fails to compile.
597
+
598
+ **Find affected code:**
599
+
600
+ ```bash
601
+ grep -rn "disableScrollOptimization" client-app/src/
602
+ ```
603
+
604
+ Remove the key from the grid's `experimental` config, and from any `xhGridExperimental` soft
605
+ config.
606
+
523
607
  ## Other Changes to Review
524
608
 
525
609
  These need no code change in most apps.
@@ -532,6 +616,14 @@ These need no code change in most apps.
532
616
  source position. Grids with no
533
617
  sort may show new rows in a different order. Set a sort, or set `maxPatchRatio: 0` in the
534
618
  store's `experimental` config to restore the old behavior.
619
+ - **Filters that read outside state need `refreshFilter()`.** A patched change re-tests only the
620
+ records it touches. A `FunctionFilter` that reads state other than the record, such as a model
621
+ property, no longer re-runs on the other records when data changes. Call
622
+ `store.refreshFilter()` when that state changes. v88 hid a missing call by re-filtering every
623
+ record on each change.
624
+ - **`PUT` requests default to a form-encoded body.** `FetchService` now labels every `PUT` without
625
+ an explicit `Content-Type` as `application/x-www-form-urlencoded`, as it does for `POST`. v88
626
+ sent `text/plain`. A `PUT` that sends a raw `body` should set its own `Content-Type`.
535
627
 
536
628
  ## Verification Checklist
537
629
 
@@ -553,6 +645,7 @@ After completing all steps:
553
645
  - [ ] No `columnLibrary` config sets `autoExpandOnFilter`.
554
646
  - [ ] The Options dialog shows theme and sizing choices as intended - cards, or a segmented
555
647
  control where `previewCards: false` is set.
648
+ - [ ] No grid sets `agOptions.groupDefaultExpanded`, and grouped grids open to the intended level.
556
649
 
557
650
  ## Reference
558
651
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xh/hoist",
3
- "version": "89.0.0",
3
+ "version": "89.0.1",
4
4
  "description": "Hoist add-on for building and deploying React Applications.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -48,6 +48,14 @@ if (!window.screen.orientation) {
48
48
  });
49
49
  }
50
50
 
51
+ // jsdom has no ResizeObserver, which the useOnResize hook creates. jsdom has no layout either, so
52
+ // an element never resizes - observe nothing.
53
+ globalThis.ResizeObserver ??= class {
54
+ observe() {}
55
+ unobserve() {}
56
+ disconnect() {}
57
+ };
58
+
51
59
  // Onsen UI, loaded by the mobile platform, throws "Invalid state" at import unless the root
52
60
  // element's computed style lists a transition property. jsdom lists only the properties set on it.
53
61
  document.documentElement.style.transitionDuration = '0s';