Volto version: observed on 19.1.5 (production); affected code verified unchanged on main at time of writing (last commits touching the files: 2024-12-08 dd35efb, the barrel-import removal).
Summary
On a site where anonymous users cannot view the post-logout landing page (e.g. a fully-private intranet where every content object 401s for anonymous), clicking Log out completes the logout correctly — the @logout POST succeeds, LOGOUT_SUCCESS clears userSession.token, the auth cookie is removed, the user is redirected to the login page — but the UI keeps rendering the logged-in chrome: any component reading state.actions.actions still sees the logged-in action set, and the navigation still lists every item the user could see while authenticated. The stale state persists indefinitely until a hard page reload.
The user-visible effect is a logout button that appears to do nothing: the header still offers "Log out" and the nav still shows private item titles on the login page. (No server-side exposure — the titles are already in the browser's Redux store; all API access is correctly 401.)
This is amplified by themes whose login/logout UI reads @actions rather than the token — e.g. @kitconcept/volto-light-theme's Anontools renders "Log out" from state.actions.actions.user.
Root cause
Three pieces interact:
-
Default apiExpanders (packages/volto/src/config/index.js, the block appended after the settings object — match: '' with GET_CONTENT: ['breadcrumbs', 'actions', 'types', 'navroot', 'translations'] plus the navigation entry): actions and navigation ride on every content GET.
-
Guarded standalone fetches: because the expander is configured, View.jsx skips listActions() (if (!hasApiExpander('actions', …)) in componentDidMount / UNSAFE_componentWillReceiveProps) and navigation components skip getNavigation() behind the same guard. The actions and navigation reducers are therefore fed exclusively by GET_CONTENT_SUCCESS (the @components expander payload).
-
No logout handling in the reducers: neither reducers/actions/actions.js nor reducers/navigation/navigation.js handles LOGOUT_SUCCESS/LOGOUT_FAIL, and GET_CONTENT_FAIL touches neither reducer's data.
The logout chain (Logout.jsx → dispatch(logout()) → redirect to the return URL) therefore ends in an anonymous GET /++api++/<landing>?expand=…actions…navigation…. On a public site this succeeds and the expander payload silently refreshes both reducers — which is why the bug is invisible on demo.plone.org. On a site where that GET 401s, nothing ever re-syncs the two reducers, and the logged-in payloads survive the session they came from.
Reproduction
Any Volto site + a plone.restapi-compatible backend where the site root / landing page is not View-able by Anonymous:
- Log in; navigate to any page. Header shows "Log out", nav shows the private items.
- Click "Log out" (a client-side
<Link to="/logout"> — no page reload).
- Observe: the redirect to the login page completes, the auth cookie is removed, but the header still shows "Log out" and the nav still lists the private items. The state persists until a hard reload.
Observed network sequence after the click (headless-browser capture):
204 POST /++api++/@logout
401 GET /++api++/<landing>?expand=breadcrumbs,actions,navroot,translations,navigation,inherit&...
401 GET /++api++/<landing>/@types/<type>
No standalone @actions or @navigation request is ever issued (the expander guards suppress them), and the 401 content failure updates neither reducer.
Side observation from the same capture: the click flow dispatches three POST /@logout (all succeed; Logout.jsx appears to re-mount during the redirect cascade). Harmless because idempotent, but noted.
Suggested fix
Session-derived reducers should not outlive the session. The minimal, behavior-preserving variant we now run in production (as project-level config.addonReducers wraps; the same shape would work in core):
import { LOGOUT } from '@plone/volto/constants/ActionTypes';
const resetOnLogout = (coreReducer) => (state, action = {}) =>
action.type === `${LOGOUT}_SUCCESS` || action.type === `${LOGOUT}_FAIL`
? coreReducer(undefined, {})
: coreReducer(state, action);
applied to the actions and navigation reducers (resetting on LOGOUT_FAIL too mirrors the userSession reducer, which clears the token on both). On public sites the reset is transient — the post-logout anonymous GET_CONTENT_SUCCESS immediately refills both via the expanders. On private sites the cleared state is the correct anonymous rendering. An equivalent alternative is handling the logout action types directly inside the two core reducers.
This has been running in production on a fully-private site since 2026-07-13, verified by the same headless script that reproduced the bug (post-click: login page with anonymous-correct chrome, zero console/page errors; logged-in rendering unchanged).
Related
Volto version: observed on 19.1.5 (production); affected code verified unchanged on
mainat time of writing (last commits touching the files: 2024-12-08 dd35efb, the barrel-import removal).Summary
On a site where anonymous users cannot view the post-logout landing page (e.g. a fully-private intranet where every content object 401s for anonymous), clicking Log out completes the logout correctly — the
@logoutPOST succeeds,LOGOUT_SUCCESSclearsuserSession.token, the auth cookie is removed, the user is redirected to the login page — but the UI keeps rendering the logged-in chrome: any component readingstate.actions.actionsstill sees the logged-in action set, and the navigation still lists every item the user could see while authenticated. The stale state persists indefinitely until a hard page reload.The user-visible effect is a logout button that appears to do nothing: the header still offers "Log out" and the nav still shows private item titles on the login page. (No server-side exposure — the titles are already in the browser's Redux store; all API access is correctly 401.)
This is amplified by themes whose login/logout UI reads
@actionsrather than the token — e.g.@kitconcept/volto-light-theme's Anontools renders "Log out" fromstate.actions.actions.user.Root cause
Three pieces interact:
Default apiExpanders (
packages/volto/src/config/index.js, the block appended after the settings object —match: ''withGET_CONTENT: ['breadcrumbs', 'actions', 'types', 'navroot', 'translations']plus thenavigationentry):actionsandnavigationride on every content GET.Guarded standalone fetches: because the expander is configured,
View.jsxskipslistActions()(if (!hasApiExpander('actions', …))incomponentDidMount/UNSAFE_componentWillReceiveProps) and navigation components skipgetNavigation()behind the same guard. Theactionsandnavigationreducers are therefore fed exclusively byGET_CONTENT_SUCCESS(the@componentsexpander payload).No logout handling in the reducers: neither
reducers/actions/actions.jsnorreducers/navigation/navigation.jshandlesLOGOUT_SUCCESS/LOGOUT_FAIL, andGET_CONTENT_FAILtouches neither reducer's data.The logout chain (
Logout.jsx→dispatch(logout())→ redirect to the return URL) therefore ends in an anonymousGET /++api++/<landing>?expand=…actions…navigation…. On a public site this succeeds and the expander payload silently refreshes both reducers — which is why the bug is invisible on demo.plone.org. On a site where that GET 401s, nothing ever re-syncs the two reducers, and the logged-in payloads survive the session they came from.Reproduction
Any Volto site + a plone.restapi-compatible backend where the site root / landing page is not
View-able by Anonymous:<Link to="/logout">— no page reload).Observed network sequence after the click (headless-browser capture):
No standalone
@actionsor@navigationrequest is ever issued (the expander guards suppress them), and the 401 content failure updates neither reducer.Side observation from the same capture: the click flow dispatches three
POST /@logout(all succeed;Logout.jsxappears to re-mount during the redirect cascade). Harmless because idempotent, but noted.Suggested fix
Session-derived reducers should not outlive the session. The minimal, behavior-preserving variant we now run in production (as project-level
config.addonReducerswraps; the same shape would work in core):applied to the
actionsandnavigationreducers (resetting onLOGOUT_FAILtoo mirrors theuserSessionreducer, which clears the token on both). On public sites the reset is transient — the post-logout anonymousGET_CONTENT_SUCCESSimmediately refills both via the expanders. On private sites the cleared state is the correct anonymous rendering. An equivalent alternative is handling the logout action types directly inside the two core reducers.This has been running in production on a fully-private site since 2026-07-13, verified by the same headless script that reproduced the bug (post-click: login page with anonymous-correct chrome, zero console/page errors; logged-in rendering unchanged).
Related
state.actions/state.navigationare session-scoped caches with no invalidation tied to auth changes.