@plone/volto 19.4.0 → 19.4.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
@@ -17,6 +17,12 @@ myst:
17
17
 
18
18
  <!-- towncrier release notes start -->
19
19
 
20
+ ## 19.4.1 (2026-09-10)
21
+
22
+ ### Bugfix
23
+
24
+ - Fix `SelectAutoComplete` rendering the raw token instead of its title when the vocabulary subrequest still held a stale token/title pair from a previously edited value (for example after changing a value, saving, and editing again via client-side navigation). The widget now keeps its local token/title cache in sync with the incoming choices instead of seeding it only once. @sneridagh [#8423](https://github.com/plone/volto/issues/8423)
25
+
20
26
  ## 19.4.0 (2026-09-03)
21
27
 
22
28
  ### Feature
package/package.json CHANGED
@@ -9,7 +9,7 @@
9
9
  }
10
10
  ],
11
11
  "license": "MIT",
12
- "version": "19.4.0",
12
+ "version": "19.4.1",
13
13
  "repository": {
14
14
  "type": "git",
15
15
  "url": "git@github.com:plone/volto.git"
@@ -175,9 +175,9 @@
175
175
  "url": "^0.11.3",
176
176
  "use-deep-compare-effect": "1.8.1",
177
177
  "uuid": "^14.0.0",
178
- "@plone/registry": "3.0.1",
179
- "@plone/scripts": "4.0.1",
180
178
  "@plone/components": "4.2.1",
179
+ "@plone/registry": "3.0.2",
180
+ "@plone/scripts": "4.0.1",
181
181
  "@plone/volto-slate": "19.0.5"
182
182
  },
183
183
  "devDependencies": {
@@ -286,10 +286,10 @@
286
286
  "webpack-bundle-analyzer": "4.10.1",
287
287
  "webpack-dev-server": "^5.2.4",
288
288
  "webpack-node-externals": "3.0.0",
289
- "@plone/babel-preset-razzle": "^1.0.1",
290
289
  "@plone/razzle": "1.0.1",
291
- "@plone/razzle-dev-utils": "1.0.0",
290
+ "@plone/babel-preset-razzle": "^1.0.1",
292
291
  "@plone/volto-coresandbox": "1.0.0",
292
+ "@plone/razzle-dev-utils": "1.0.0",
293
293
  "@plone/types": "2.0.1"
294
294
  },
295
295
  "scripts": {
@@ -148,14 +148,33 @@ class SelectAutoComplete extends Component {
148
148
 
149
149
  componentDidUpdate(prevProps, prevState) {
150
150
  const { value, choices } = this.props;
151
- if (
152
- this.state.termsPairsCache.length === 0 &&
153
- value?.length > 0 &&
154
- choices?.length > 0
155
- ) {
156
- this.setState((state) => ({
157
- termsPairsCache: [...state.termsPairsCache, ...choices],
158
- }));
151
+ // Keep the local token/title cache in sync with the incoming vocabulary
152
+ // choices. Seeding the cache only once (when it was still empty) locked it
153
+ // to whatever choices happened to be present first. When the vocabulary
154
+ // subrequest still held a stale token/title pair from a previously edited
155
+ // value (its cache persists across client-side navigation), the widget kept
156
+ // rendering the raw token instead of its title after the value was changed
157
+ // and saved. Merging fresh choices as they arrive (de-duplicated, and
158
+ // preserving options the user picked) resolves the current value's label as
159
+ // soon as it is fetched.
160
+ const choicesChanged = choices !== prevProps.choices;
161
+ const cacheEmptyWithValue =
162
+ this.state.termsPairsCache.length === 0 && value?.length > 0;
163
+ if (choices?.length > 0 && (choicesChanged || cacheEmptyWithValue)) {
164
+ this.setState((state) => {
165
+ const normalizedIncoming = normalizeChoices(choices, this.props.intl);
166
+ const knownValues = new Set(
167
+ normalizeChoices(state.termsPairsCache, this.props.intl).map(
168
+ (pair) => pair.value,
169
+ ),
170
+ );
171
+ const additions = choices.filter(
172
+ (choice, index) => !knownValues.has(normalizedIncoming[index].value),
173
+ );
174
+ return additions.length > 0
175
+ ? { termsPairsCache: [...state.termsPairsCache, ...additions] }
176
+ : null;
177
+ });
159
178
  }
160
179
  }
161
180
 
@@ -1,7 +1,9 @@
1
1
  import React from 'react';
2
2
  import configureStore from 'redux-mock-store';
3
+ import { createStore, combineReducers } from 'redux';
3
4
  import { Provider } from 'react-intl-redux';
4
- import { waitFor, render, screen } from '@testing-library/react';
5
+ import { act, waitFor, render, screen } from '@testing-library/react';
6
+ import vocabularies from '@plone/volto/reducers/vocabularies/vocabularies';
5
7
  import SelectAutoComplete from './SelectAutoComplete';
6
8
 
7
9
  const mockStore = configureStore();
@@ -60,3 +62,64 @@ test('renders a select widget component', async () => {
60
62
  await waitFor(() => screen.getByText('My field'));
61
63
  expect(container).toMatchSnapshot();
62
64
  });
65
+
66
+ const VOCAB = 'plone.app.vocabularies.Keywords';
67
+
68
+ function tokenTitleSuccess(token, title) {
69
+ return {
70
+ type: 'GET_VOCABULARY_TOKEN_TITLE_SUCCESS',
71
+ vocabulary: VOCAB,
72
+ tokens: [token],
73
+ subrequest: 'widget-responsible-en',
74
+ result: { items: [{ token, title }] },
75
+ };
76
+ }
77
+
78
+ test('resolves the current value label from fresh vocabulary choices even when a stale token/title pair was cached first', async () => {
79
+ // A real store (not a mock) so that dispatching keeps the same widget
80
+ // instance mounted while its `choices` change — this is what reproduces the
81
+ // stale vocabulary cache surviving across a client-side navigation.
82
+ const intl = (state = { locale: 'en', messages: {} }) => state;
83
+ const store = createStore(combineReducers({ intl, vocabularies }));
84
+
85
+ // The vocabulary subrequest still holds a stale pair left by a previously
86
+ // edited value; it does not contain the current value ("anna").
87
+ store.dispatch(tokenTitleSuccess('max', 'Max Berger'));
88
+
89
+ render(
90
+ <Provider store={store}>
91
+ <SelectAutoComplete
92
+ widgetOptions={{ vocabulary: { '@id': VOCAB } }}
93
+ id="responsible"
94
+ title="Responsible"
95
+ fieldSet="default"
96
+ isMulti={false}
97
+ value="anna"
98
+ onChange={() => {}}
99
+ onBlur={() => {}}
100
+ onClick={() => {}}
101
+ />
102
+ </Provider>,
103
+ );
104
+
105
+ // With only the stale pair cached, the raw token is shown at first.
106
+ await waitFor(() => screen.getByText('anna'));
107
+
108
+ // A refresh of the vocabulary subrequest that still resolves the stale pair
109
+ // makes the widget seed its local cache from those stale choices — this is
110
+ // the state that used to lock the widget to the wrong label.
111
+ act(() => {
112
+ store.dispatch(tokenTitleSuccess('max', 'Max Berger'));
113
+ });
114
+ await waitFor(() => screen.getByText('anna'));
115
+
116
+ // The choices for the current value finally arrive (same mounted instance).
117
+ // The widget must pick up the fresh pair and render its title instead of
118
+ // staying locked to the stale cache it seeded earlier.
119
+ act(() => {
120
+ store.dispatch(tokenTitleSuccess('anna', 'Anna Becker'));
121
+ });
122
+
123
+ await waitFor(() => screen.getByText('Anna Becker'));
124
+ expect(screen.queryByText('anna')).not.toBeInTheDocument();
125
+ });