feat(websockets): migrate WebSocket stack to data-access and react to site CRUD events#35378
Conversation
… site CRUD events
- Move WebSocket service from deprecated dotcms-js to libs/data-access as DotEventsSocket
using native WebSocket only (dropped long-polling — all modern browsers support WS)
- Add withWebSocket() feature to GlobalStore managing connection lifecycle, wsStatus signal,
portletLayoutUpdated and siteEvents observables
- Replace DotcmsEventsService.subscribeTo('UPDATE_PORTLET_LAYOUTS') in DotNavigationService
with globalStore.portletLayoutUpdated
- React to site CRUD events (SAVE, PUBLISH, UN_PUBLISH, UPDATE, ARCHIVE, UN_ARCHIVE, DELETE)
in DotSiteComponent: debounce list refresh, switch to default site when selected site
becomes unavailable (archived, stopped, deleted)
- Add UN_PUBLISH_SITE to SystemEventType so unpublishHost DWR fires the WebSocket event
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Fix timer subscription leak in scheduleReconnect() — store subscription in reconnectTimer and cancel on destroy() - Guard openSocket() against concurrent calls when socket is CONNECTING - Remove dead getLongPollingURL() method from DotEventsSocketURL - Update spec mock to remove getLongPollingURL reference - Remove invalid test in with-websocket.feature.spec.ts that assumed rxMethod would propagate errors and set wsStatus to closed (rxMethod swallows errors and connect() completes immediately, never throws) - Add dot-events-socket.service.spec.ts (was untracked) - Fix stale JSDoc in refreshSelectedSite() — "archive/un-archive" → "unavailable events" Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace the siteListRefresh$ Subject + two subscriptions pattern with a single pipe using tap + debounceTime directly on the merged site events observable. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…t mock - Add 'WebSocket site events' describe block to dot-site.component.spec.ts covering: debounced list refresh, re-fetch on update, no-op for other sites, switch-to-default on ARCHIVE/UN_PUBLISH/DELETE, error fallbacks, and unsubscribe on destroy - Add mockProvider(DotEventsSocket) to dot-site.component.spec.ts factories - Fix dot-theme.component.spec.ts: add mockProvider(DotEventsSocket) since DotThemeComponent embeds DotSiteComponent which now injects DotEventsSocket Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ing token The class existed only to build a ws/wss URL from hostname + SSL flag. Replace with InjectionToken<string> — the factory in providers.ts returns the URL string directly, and DotEventsSocket injects it as a plain string. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ion.host Use window.location.host (hostname + port combined) instead of manually concatenating hostname and port separately. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
DotEventsSocket now derives its URL directly from window.location — no injection token or external configuration needed. Removes DOT_EVENTS_SOCKET_URL token, DotEventsSocketURL file, and the factory in providers.ts entirely. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add switchSiteEvent$() to withWebSocket feature — emits the new DotSite from the SWITCH_SITE payload (no HTTP call needed) - DotToolbarComponent now uses globalStore.siteDetails as $currentSite (signal) instead of a one-shot getCurrentSite() observable - On SWITCH_SITE: call setCurrentSite() and navigate away from edit page - Remove stale ARCHIVE_SITE handler and DotcmsEventsService dependency (archive is already handled by DotSiteComponent directly) - Update toolbar spec: add SWITCH_SITE tests, remove dotcms-js imports Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The toolbar was acting as a mediator — calling DotSiteService directly and then writing back to the store it was reading from. Move that responsibility where it belongs: - Add switchCurrentSite(identifier) to GlobalStore: calls switchSite() → getCurrentSite() → patchState internally - Handle SWITCH_SITE WebSocket event inside store onInit instead of the toolbar, removing the need for setCurrentSite() entirely - DotToolbarComponent now delegates to globalStore.switchCurrentSite() and only owns the navigation side effect (goToSiteBrowser) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ionEffect Navigation away from the edit page on site switch is an app-level concern, not a toolbar concern. Move it into a dedicated effect service that lives for the full application lifetime: - Add DotSiteNavigationEffect: subscribes to switchSiteEvent$() and calls goToSiteBrowser() when on edit page - Register it eagerly via providers.ts (providedIn root + listed) - DotToolbarComponent is now a pure UI component with no lifecycle hooks - Add DotSiteNavigationEffect.spec.ts covering both navigation paths Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…teNavigationEffect DotSiteNavigationEffect already handles goToSiteBrowser() when a SWITCH_SITE WebSocket event fires, which includes user-initiated switches from the toolbar. Removing the duplicated check keeps navigation responsibility in one place. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…obalStore.switchSiteEvent$ Replace all usages of the legacy dotcms-js SiteService.switchSite$ observable in apps/dotcms-ui with GlobalStore.switchSiteEvent$, which is backed by the WebSocket SWITCH_SITE event. This consolidates site-switch reactivity through the global store. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Migrate all subscribeTo/subscribeToEvents call sites to the new DotEventsSocket.on<T>() API. Also fixes a memory leak in DotToolbarNotificationsComponent by adding takeUntilDestroyed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Wire switchSiteEvent$ mock before service creation in DotSiteNavigationEffect spec so the constructor subscription does not receive undefined - Fix GlobalStore SWITCH_SITE test: move mock setup into beforeEach before createService so onInit subscribes to the correct subject - Remove goToSiteBrowser assertion from toolbar test — navigation now lives in DotSiteNavigationEffect, not the toolbar component - Fix misleading onclose comment: 1001 is going-away (tab close), not 1000 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace SiteService.switchSite$ with GlobalStore.switchSiteEvent$() in the component test, matching the production code change on this branch. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…-switch Replace SiteService.switchSite$ with GlobalStore.switchSiteEvent$() in the component test, matching the production code change on this branch. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…d DotEventsSocket teardown error The real GlobalStore includes withWebSocket() whose onDestroy calls inject(DotEventsSocket) outside an injection context during test cleanup. DotSubNavComponent doesn't use GlobalStore at all, so mocking it is correct. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…vent NG0203 in tests `inject()` called as a default parameter in `onDestroy` fires during R3Injector.destroy(), after the injection context is gone. Capture the DotEventsSocket reference in withMethods() via a new destroySocket() method and call that from onDestroy() instead. Also adds DotEventsSocket mock to dot-navigation.service.spec.ts so GlobalStore can initialize withWebSocket without opening a real socket. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Move createServiceFactory to describe level (Spectator requirement) and override GlobalStore per-test via createService() options so each test gets a fresh Subject for switchSiteEvent$. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…itch Replace SiteService.switchSite$ with GlobalStore.switchSiteEvent$() in the component test, matching the production code change on this branch. Update site-switch assertion to expect paginatorService.getFirstPage() instead of get() since getContainersByHost calls getFirstPage(). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Template was still referencing the old public `globalStore.siteDetails()` property after the store was made private. Update to use the `$currentSite` signal alias exposed on the component. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ockWebSocket static constants - store.spec.ts: move createServiceFactory to describe level to avoid Spectator "Hooks cannot be defined inside tests" error - dot-events-socket.service.spec.ts: add static CONNECTING/OPEN/CLOSING/CLOSED constants to MockWebSocket so the guard in openSocket() doesn't false-match when global.WebSocket is replaced with the mock (undefined === undefined) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ing Math.random Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…uracy, and mock cleanup - container-list: pipe switchSiteEvent$ through takeUntil(destroy$) to prevent subscription leak on destroy - providers: use provideAppInitializer to force DotSiteNavigationEffect instantiation at startup - dot-site-navigation.effect.spec: remove redundant describe-level mock; clarify beforeEach ordering - dot-events-socket.service: clarify onclose comment (1001 vs 1000 distinction) - dot-events-socket.service.spec: rename test to match what it actually asserts - store.spec: add comment explaining beforeEach ordering; fix switchSite mock type - dot-site.component.spec: restore DotEventsSocket per-event subject wiring (was commented out) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… dual WebSocket connections Migrates the last 4 consumers to use DotEventsSocket from @dotcms/data-access or GlobalStore event streams, then deletes the entire legacy WebSocket layer. - LoginService: SESSION_DESTROYED/SESSION_LOGOUT now wired to DotEventsSocket directly - RoutingService: UPDATE_PORTLET_LAYOUTS now wired to DotEventsSocket (avoids circular dep) - SiteService: event subscriptions removed (data API unchanged); deprecated class kept - DotPluginsListStore: OSGI events migrated from DotcmsEventsService to DotEventsSocket - Removed DotcmsEventsService, DotEventsSocket (legacy), DotEventsSocketURL from all provider arrays - Deleted: dotcms-events.service.ts, dot-event-socket.ts, long-polling-protocol.ts, websockets-protocol.ts, dot-event-socket-url model, and all related spec files - Cleaned barrel exports in dotcms-js/src/public_api.ts - Updated ~60 spec files to remove dead provider mocks for the deleted services Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…anguage version?' (#35867) Fixes #35647. After creating a new language version of a page via the **Properties dialog** (the edit-page portlet opened from the UVE toolbar), the UVE toolbar language dropdown incorrectly showed the **\"Create New Language Version?\"** confirmation dialog for a version that had just been created — requiring a full browser refresh to recover. The bug has two layers: **Layer 1 — Stale reactive computation (`withPage.ts`)** `pageTranslateProps` used `untracked(() => store.pageLanguages())`, making it invisible to changes in `pageLanguages`. It only recomputed when `pageAsset` changed. When `pageReload()` updated `pageAsset` before `getLanguagesUsedPage` responded, `pageTranslateProps` recomputed using the old (stale) language data, causing `$translatePageEffect` to see `translated: false` and incorrectly trigger the dialog. **Layer 2 — Missing event handler in the shell (`dot-ema-shell.component.ts`)** When a user selects a new language inside the Properties dialog, the iframe navigates to a new URL with an empty inode, creating a working draft. When the user saves, the JSP's `saveContentCallback` detects `workingContentletInode.length === 0` and fires `LANGUAGE_IS_CHANGED` instead of `SAVE_PAGE`. The shell's `handleNgEvent` had no `case` for `LANGUAGE_IS_CHANGED`, so the event was silently dropped and `pageLanguages` was never refreshed. For the **workflow action path** (e.g. clicking a \"Publish\" workflow button), `handleWorkflowEvent` in the dialog calls `saveAssignCallBackAngular` inside the iframe, which starts an async AJAX save. By the time `saveContentCallback` fires `LANGUAGE_IS_CHANGED`, the dialog may already be closed and its iframe document listener destroyed. The `reloadFromDialog.emit()` call immediately after `callEmbeddedFunction` ensures the reload runs while the dialog is still open — and it already returns correct data because the language draft was created the moment the user navigated to the new language URL. - **`withPage.ts`** — Remove `untracked()` from `pageTranslateProps` so it is reactive on both `pageAsset` and `pageLanguages`. Angular batches synchronous signal writes before flushing effects, so updating both in the same `tap` results in a single consistent effect execution. - **`withPageApi.ts` / `withWorkflow.ts`** — Move `patchState({ pageLanguages })` and `setPageAsset` into the same synchronous `tap` callback so both signals are always written together, preventing any window where `pageTranslateProps` could see a partially-updated state. - **`dot-ema-shell.component.ts`** — Add `LANGUAGE_IS_CHANGED` case to `handleNgEvent`. This covers the direct-save path where the JSP fires the event after the new language version is persisted. - **`dot-ema-dialog.component.ts`** — Keep `reloadFromDialog.emit()` in `handleWorkflowEvent` to cover the workflow-action path, where the async save callback may fire after the dialog is already closed. Remove the equivalent emit from the `EDIT_CONTENTLET_UPDATED / isTranslation` branch — that branch is only reached from the editor's own translation dialog, which already handles `LANGUAGE_IS_CHANGED` independently. - `withPage.ts` — remove `untracked()` from `pageTranslateProps`; root cause fix - `withPageApi.ts` — restructure `pageReload` pipeline; align `pageLoad` write order - `withWorkflow.ts` — same restructuring for `reloadPageAfterLockChange` - `dot-ema-shell.component.ts` — add `LANGUAGE_IS_CHANGED` case to `handleNgEvent` - `dot-ema-dialog.component.ts` — clarify workflow reload comment; remove redundant `EDIT_CONTENTLET_UPDATED` emit - Spec files for all of the above - [ ] Open a page in the UVE. Click **Properties** in the toolbar → in the dialog, change the language dropdown to a language that has no version yet → fill in the form → click **Publish** → close the dialog → click the language dropdown in the UVE toolbar → the language switches without showing \"Create new language version?\" - [ ] Switch between existing language translations in the UVE toolbar — no spurious dialog appears - [ ] Toggle page lock — no spurious \"Create language version?\" dialog after the reload - [ ] `yarn nx test portlets-edit-ema-portlet --testPathPattern="withPageApi.spec|withWorkflow.spec|withPage.spec|dot-ema-dialog|dot-ema-shell"` --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…st on headless navigation (#35927) https://github.com/user-attachments/assets/f1d6a520-53a4-4c7b-9dc1-a94e78383794 This PR fixes #35926 ### Problem In headless mode, opening "Page One" in UVE and then navigating to "Page Two" could fail: the fetch for Page Two errored out and the editor bounced back to Page One, getting stuck on it. Traditional pages were not affected. ### Root cause When a headless app boots inside UVE, the SDK sends `CLIENT_READY` with the page's GraphQL request (query + **variables**: `url`, `languageId`, `publishDate`, `siteId`, custom vars, etc.). UVE stores it as `requestMetadata` and prefers it for every subsequent page fetch. Two recent refactors broke the lifecycle of that stored request: 1. **#34173 (Real-Time Canvas)** renamed `graphql` → `requestMetadata`. The old `resetClientConfiguration()` reset the whole client state (including the stored query); the new implementation no longer cleared it. 2. **#35539 (explicit iframe sizing)** changed `pageLoad` to call `markPageLoading()` instead of `resetClientConfiguration()` (intentionally, to keep the editor chrome mounted during navigation). `markPageLoading()` only resets `isClientReady` + history — `requestMetadata` survives every navigation. On top of that, the `CLIENT_READY` handler early-returned when `isClientReady` was already `true`. During a client-side route change inside the headless app, the SDK sends `CLIENT_READY` (for the new page) **before** `NAVIGATION_UPDATE` — so the new page's request was **discarded** while the editor was still marked ready for the previous page. **Failure chain:** navigate to Page Two → `pageLoad` fetches it via GraphQL using **Page One's stored query/variables** → the query can resolve to `page: null` (stale variables) → `graphqlToPageEntity` returns `null` → `const { vanityUrl } = pageAsset` throws → `uveStatus: ERROR` with no HTTP status (no error UI). Since `markPageLoading()` keeps the previous `pageAssetResponse`, `$iframeURL` still derives from **Page One** → the iframe reloads Page One → the app re-announces `updateNavigation('/page-one')` → UVE loads Page One again. That's the "falls back to the last page" behavior. ### Proposed Changes * **`dot-uve-actions-handler.service.ts` (`CLIENT_READY`)** — the stored client request is now refreshed on every `CLIENT_READY` (it is page-scoped data). When the client is already ready, only the config is updated — no `pageReload` is fired, so no extra request happens. This means that by the time a client-side navigation's `NAVIGATION_UPDATE` triggers `pageLoad`, the store already holds the **target page's** query/variables and the fetch goes through GraphQL with the correct config. * **`withPageApi.ts` (`pageLoad`)** — before fetching, the store now checks whether the stored request **belongs to the target page** by comparing the request's own `variables.url` against the navigation target (`compareUrlPaths`, so leading-slash variants match). Requests without a `url` variable are assumed to belong to the currently loaded page (preserves legacy-client behavior). * Belongs to the target → GraphQL fetch, same as today. * Belongs to another page (e.g. navigation from the toolbar / Pages portlet, where no `CLIENT_READY` for the target exists yet) → the stale request is dropped and the page loads through the standard Page API — which is exactly the first-load flow — until the page's own `CLIENT_READY` installs fresh metadata. * **`withPage.ts`** — new `resetRequestMetadata()` method (drops only the stored client request), wired into `withPageApi` deps in `dot-uve.store.ts`. ### What deliberately did NOT change | Flow | Behavior | |---|---| | Same-page param changes (language / persona / mode) | Unchanged — GraphQL with the stored request, as today | | Page reload / save / style editor | Unchanged | | Traditional pages | Unchanged (they never use `requestMetadata`) | | Request count on navigation | Unchanged — same number of editor-side fetches as today, only the *config* used is corrected | | `resetClientConfiguration()` | Untouched (currently has no production callers; left out of scope on purpose) | **Known constraint (legacy clients):** a custom GraphQL client that omits the `url` variable from its `CLIENT_READY` request is assumed to belong to the page currently loaded in the editor. On cross-page navigation its stored request is dropped and the page loads through the standard Page API until the client re-announces itself — this matches the pre-#34173 behavior, where the stored request was reset on every `pageLoad`. The SDK (`@dotcms/client`) always sends `url`, so this only affects hand-rolled clients. ### Checklist - [x] Tests - [ ] Translations (N/A — no user-facing text) - [x] Security Implications Contemplated (no new attack surface: `CLIENT_READY` could already overwrite the stored request before the editor was ready; messages still come from the same UVE iframe channel) ### Additional Info **Tests added:** * `withPageApi.spec.ts`: navigating to a different page drops the stale request and uses the Page API; a request captured *for* the target page is kept (client-side navigation); same-page param changes keep the request; a fresh load with no previous `pageParams` drops stale metadata surviving from a prior visit (the store lives at the route level and outlives the shell). * `dot-uve-actions-handler.service.spec.ts`: new `CLIENT_READY` suite — installs config + reloads when not ready; refreshes config **without** reloading when already ready; no-ops without a graphql query. * `withPage.spec.ts`: `resetRequestMetadata()` drops only the stored request. Full `portlets-edit-ema-portlet` suite: 70 suites / 1418 passing. Lint clean. **Assumption worth knowing:** the client-side navigation path relies on the SDK sending `CLIENT_READY` before `NAVIGATION_UPDATE` (both are emitted in that order from the same effect in `useEditableDotCMSPage` / `DotCMSEditablePageService.listen`). If that order ever changes, behavior degrades safely to the Page API path (first-load flow), not to a failure. **Follow-ups (separate issues recommended):** 1. Make the `CLIENT_READY` payload carry an explicit page identity (typed in `@dotcms/types`) instead of inferring ownership from `variables.url` — removes the implicit contract between the editor store and the SDK's GraphQL variable naming. 2. `@dotcms/client` regression in `page.get()`: `UVE_MODE[mode]` returns `undefined` when apps forward the raw `mode=EDIT_MODE` URL param (string enums have no reverse mapping), silently falling back to LIVE — unpublished pages 404 inside the editor. 3. No E2E covers "navigate between two headless pages in UVE" — both refactors that introduced this regression passed CI. --------- Co-authored-by: Kevin <kfariid@gmail.com>
…com-dotCMS-core-pull-35378
…at:check CI format:check flagged dot-toolbar.component.html (flex gap-1 items-center -> flex items-center gap-1). The prettier-plugin-tailwindcss sort was missed locally because the plugin wasn't installed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Notes to QAThis PR replaces the legacy WebSocket/long-polling stack with a single native socket. Open DevTools → Network → WS and confirm exactly one Where the UI reacts to WebSocket events
Test casesConnection lifecycle
SWITCH_SITE — use two tabs / two users
Notifications / messages
Contentlet / component editing
Plugins (OSGi)
Menu / regression
Priority: connection lifecycle → SWITCH_SITE → |
82933b7 to
a1c126e
Compare
websocket.mp4
TL;DR
Builds a new native WebSocket stack in
@dotcms/data-access, wires it into the app to react to site CRUD events and toolbar navigation, and removes the second WebSocket connection that the legacyDotcmsEventsServiceused to open — so only one connection to/api/ws/v1/system/eventsever runs.What changed
New WebSocket stack (
libs/data-access+libs/global-store)DotEventsSocket— native WebSocket only (long-polling dropped; all modern browsers support WS), exponential backoff with jitter, typedon<T>(eventType)API, reactivestatus$()streamwithWebSocket()NgRx signal store feature — manages connection lifecycle at app init viaprovideAppInitializer, exposeswsStatussignal and typed event observables:switchSiteEvent$,portletLayoutUpdated$,siteEvents$, plus iframe-specificiframeLegacyEvents$/osgiBundlesLoaded$Site CRUD reactions
DotSiteComponentdebounces list refresh on any site WebSocket event; auto-switches to default site when the selected site is archived, stopped, or deletedSystemEventType.UN_PUBLISH_SITEadded — was missing, causing stop-site events to be silently swallowedSWITCH_SITEevent viaDotSiteNavigationEffect(extracted from toolbar component)Migration of
DotcmsEventsServiceconsumersReplaced
subscribeTo()/subscribeToEvents()withDotEventsSocket.on<T>()orGlobalStoreevent streams in:DotMessageDisplayServiceDotToolbarNotificationsComponent(also fixed missingtakeUntilDestroyedmemory leak)DotLargeMessageDisplayComponentIframeComponent/IframePortletLegacyComponentLoginService—SESSION_DESTROYED/SESSION_LOGOUTRoutingService—UPDATE_PORTLET_LAYOUTSDotPluginsListStore—OSGI_FRAMEWORK_RESTART/OSGI_BUNDLES_LOADED/OSGI_BUNDLES_UPLOAD_FAILEDSiteService— event subscriptions removed (data API and class kept;@deprecated)Deleted — legacy WebSocket transport layer
DotEventsSocket(dotcms-js version),DotEventsSocketURLinjection token,dotEventSocketURLFactoryWebSocketProtocol,LongPollingProtocol,Protocol,DotWebSocketConfigDotcmsEventsServiceMockfrom@dotcms/utils-testing@dotcms/dotcms-jsKept (deprecated) —
DotcmsEventsServiceDotcmsEventsServiceis still present but reduced to a pureSubject-based event bus with no WebSocket logic. It's marked@deprecatedand fed bywithWebSocket().feedLegacyEventBus()from the single canonical socket. Itsstart()/destroy()are no-ops.Why not delete it in this PR: every direct consumer in dotCMS itself was migrated above, but the class is part of the public
@dotcms/dotcms-jssurface and may be used by external integrations / customer plugins. Removing it is a breaking API change that belongs in a separate cleanup PR once we've confirmed no external consumers, or once a deprecation cycle has passed.Test plan
UPDATE_PORTLET_LAYOUTSeventyarn nx test data-accesspassesyarn nx test global-storepassesyarn nx test portlets-dot-plugins-portletpasses🤖 Generated with Claude Code
This PR fixes: #35350