Skip to content

feat(websockets): migrate WebSocket stack to data-access and react to site CRUD events#35378

Merged
erickgonzalez merged 59 commits into
mainfrom
fix-websockets-v3
Jun 10, 2026
Merged

feat(websockets): migrate WebSocket stack to data-access and react to site CRUD events#35378
erickgonzalez merged 59 commits into
mainfrom
fix-websockets-v3

Conversation

@fmontes

@fmontes fmontes commented Apr 17, 2026

Copy link
Copy Markdown
Member
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 legacy DotcmsEventsService used to open — so only one connection to /api/ws/v1/system/events ever 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, typed on<T>(eventType) API, reactive status$() stream
  • withWebSocket() NgRx signal store feature — manages connection lifecycle at app init via provideAppInitializer, exposes wsStatus signal and typed event observables: switchSiteEvent$, portletLayoutUpdated$, siteEvents$, plus iframe-specific iframeLegacyEvents$ / osgiBundlesLoaded$

Site CRUD reactions

  • DotSiteComponent debounces list refresh on any site WebSocket event; auto-switches to default site when the selected site is archived, stopped, or deleted
  • Java SystemEventType.UN_PUBLISH_SITE added — was missing, causing stop-site events to be silently swallowed
  • Toolbar reacts to SWITCH_SITE event via DotSiteNavigationEffect (extracted from toolbar component)

Migration of DotcmsEventsService consumers

Replaced subscribeTo() / subscribeToEvents() with DotEventsSocket.on<T>() or GlobalStore event streams in:

  • DotMessageDisplayService
  • DotToolbarNotificationsComponent (also fixed missing takeUntilDestroyed memory leak)
  • DotLargeMessageDisplayComponent
  • IframeComponent / IframePortletLegacyComponent
  • LoginServiceSESSION_DESTROYED / SESSION_LOGOUT
  • RoutingServiceUPDATE_PORTLET_LAYOUTS
  • DotPluginsListStoreOSGI_FRAMEWORK_RESTART / OSGI_BUNDLES_LOADED / OSGI_BUNDLES_UPLOAD_FAILED
  • SiteService — event subscriptions removed (data API and class kept; @deprecated)

Deleted — legacy WebSocket transport layer

  • DotEventsSocket (dotcms-js version), DotEventsSocketURL injection token, dotEventSocketURLFactory
  • WebSocketProtocol, LongPollingProtocol, Protocol, DotWebSocketConfig
  • DotcmsEventsServiceMock from @dotcms/utils-testing
  • Associated spec files and barrel exports from @dotcms/dotcms-js
  • ~60 spec files cleaned of dead provider mocks

Kept (deprecated) — DotcmsEventsService

DotcmsEventsService is still present but reduced to a pure Subject-based event bus with no WebSocket logic. It's marked @deprecated and fed by withWebSocket().feedLegacyEventBus() from the single canonical socket. Its start() / 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-js surface 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

  • WebSocket connects on app load; no status indicator shown when connected
  • Stop/archive/delete the currently selected site — site selector switches to default site
  • Publish/update a site — site list refreshes (debounced ~300ms)
  • Navigate portlets — menu reloads on UPDATE_PORTLET_LAYOUTS event
  • Kill and restart dotCMS server — UI reconnects automatically (reconnecting indicator visible)
  • yarn nx test data-access passes
  • yarn nx test global-store passes
  • yarn nx test portlets-dot-plugins-portlet passes

🤖 Generated with Claude Code

This PR fixes: #35350

fmontes and others added 30 commits March 14, 2026 22:22
… 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>
gortiz-dotcms and others added 2 commits June 4, 2026 15:09
…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>
@fmontes fmontes requested a review from a team as a code owner June 4, 2026 21:11
…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>
@fmontes

fmontes commented Jun 5, 2026

Copy link
Copy Markdown
Member Author

Notes to QA

This PR replaces the legacy WebSocket/long-polling stack with a single native socket. Open DevTools → Network → WS and confirm exactly one /api/ws/v1/system/events connection throughout testing.

Where the UI reacts to WebSocket events

Area Event(s) What to expect
Toolbar site selector SWITCH_SITE Reflects current site across tabs
Edit page (UVE/Edit Page) SWITCH_SITE Redirects to Site Browser when site changes elsewhere
Legacy iframe portlet SWITCH_SITE / AI_CONTENT_PROMPT Iframe reloads / AI results refresh
Site Browser iframe file/folder/link/page-asset events, DELETE_BUNDLE, PAGE_RELOAD Iframe reloads / refreshes
Site selector (dot-site) site SAVE/PUBLISH/UPDATE/ARCHIVE/UN_ARCHIVE/UN_PUBLISH/DELETE List refreshes; auto-switches to default if the selected site becomes unavailable
Notifications bell NOTIFICATION Live notification + unread count
Toasts / dialogs MESSAGE / LARGE_MESSAGE Portlet-scoped toast / dialog
Left menu UPDATE_PORTLET_LAYOUTS Menu + routes reload
Plugins list OSGI_FRAMEWORK_RESTART / OSGI_BUNDLES_LOADED / OSGI_BUNDLES_UPLOAD_FAILED Restart status / reload / error toast
Containers & Templates lists SWITCH_SITE Reload / navigate back to list
Session SESSION_DESTROYED / SESSION_LOGOUT Logout

edit_contentlet.jsp has no WebSocket code — it's a producer. Saving/publishing a contentlet emits SAVE_PAGE_ASSET / SAVE_FILE_ASSET / SAVE_SITE / SAVE_CONTENT (by content type), consumed by the surfaces above. The only changed edit-flow reaction is AI_CONTENT_PROMPTrefreshFakeJax.

Test cases

Connection lifecycle

  • Login → exactly one WS connection; stays at one while navigating portlets.
  • Restart dotCMS (or drop the network) → socket reconnects and events resume.
  • Logout (or destroy session in a second tab) → socket closes, user is logged out.

SWITCH_SITE — use two tabs / two users

  • Switch site in tab A → tab B toolbar reflects it without refresh.
  • Tab B on an edit page → switch in tab A → tab B redirects to Site Browser.
  • Tab B on a non-edit portlet → no forced navigation, but current site updates.
  • Tab B on a JSP portlet → iframe reloads on switch.
  • Tab B on Containers → list reloads for the new host.
  • Tab B on Templates list / template editor → navigates back to template list.

dot-site selector

  • SAVE/PUBLISH/UPDATE/UN_ARCHIVE a site elsewhere → list refreshes (rapid events coalesce).
  • ARCHIVE / UN_PUBLISH / DELETE the currently selected site → selector auto-switches to default, value is not stale.
  • Same events on a non-selected site → only the list refreshes, selection stays put.

Notifications / messages

  • Server notification → bell prepends it + unread count increments (list caps at 25).
  • MESSAGE for current portlet → toast; for another portlet → no toast.
  • LARGE_MESSAGE → dialog opens, renders body, closes.

Contentlet / component editing

  • Save a component/content, a page, and a file → no console errors, still one socket; other tabs/lists viewing the same area update.
  • Edit a Host/Site contentlet → site selector list refreshes.
  • AI content generation in the legacy editor → AI_CONTENT_PROMPT triggers refreshFakeJax, results reload.

Plugins (OSGi)

  • Upload a plugin → status shows restarting, then list refreshes (~4s after bundles load).
  • Upload failure → status returns to loaded + error toast.

Menu / regression

  • Change a portlet layout in another session → menu + routes reload, current portlet still reachable.
  • Open/close subscribing portlets repeatedly → no duplicate reactions (e.g. one reload per site switch, not many).

Priority: connection lifecycle → SWITCH_SITE → dot-site unavailable-site handling → contentlet editing → the rest.

@github-actions github-actions Bot added Area : Documentation PR changes documentation files and removed AI: Safe To Rollback labels Jun 5, 2026
@fmontes fmontes force-pushed the fix-websockets-v3 branch from 82933b7 to a1c126e Compare June 5, 2026 12:23
@github-actions github-actions Bot removed the Area : Documentation PR changes documentation files label Jun 5, 2026
@erickgonzalez erickgonzalez added this pull request to the merge queue Jun 10, 2026
Merged via the queue into main with commit 09a7499 Jun 10, 2026
58 checks passed
@erickgonzalez erickgonzalez deleted the fix-websockets-v3 branch June 10, 2026 15:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI: Safe To Rollback Area : Backend PR changes Java/Maven backend code Area : Frontend PR changes Angular/TypeScript frontend code

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

Migrate WebSocket stack to data-access and react to site CRUD events