diff --git a/.changeset/session-resume-skeleton.md b/.changeset/session-resume-skeleton.md new file mode 100644 index 0000000000000..d969665f612a2 --- /dev/null +++ b/.changeset/session-resume-skeleton.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Shows the loading skeleton instead of flashing the login form while a stored session is being resumed diff --git a/apps/meteor/client/hooks/useStoredItem.spec.ts b/apps/meteor/client/hooks/useStoredItem.spec.ts new file mode 100644 index 0000000000000..1d1ca8d347e7a --- /dev/null +++ b/apps/meteor/client/hooks/useStoredItem.spec.ts @@ -0,0 +1,98 @@ +import { act, renderHook } from '@testing-library/react'; + +import { useStoredItem } from './useStoredItem'; +import { STORAGE_KEYS, removeStoredItem, setStoredItem } from '../lib/sdk/storage'; + +afterEach(() => { + localStorage.clear(); +}); + +it('reads what is already stored', () => { + localStorage.setItem(STORAGE_KEYS.LOGIN_TOKEN, 'a-stored-token'); + + const { result } = renderHook(() => useStoredItem(STORAGE_KEYS.LOGIN_TOKEN)); + + expect(result.current).toBe('a-stored-token'); +}); + +it('reads null for a key with nothing under it', () => { + const { result } = renderHook(() => useStoredItem(STORAGE_KEYS.LOGIN_TOKEN)); + + expect(result.current).toBeNull(); +}); + +// The whole point of the hook: `localStorage` announces a same-tab write to nobody, so the writers have to. +it('re-renders when the value is written', () => { + const { result } = renderHook(() => useStoredItem(STORAGE_KEYS.LOGIN_TOKEN)); + + act(() => { + setStoredItem(STORAGE_KEYS.LOGIN_TOKEN, 'a-fresh-token'); + }); + + expect(result.current).toBe('a-fresh-token'); +}); + +it('re-renders when the value is removed', () => { + localStorage.setItem(STORAGE_KEYS.LOGIN_TOKEN, 'a-token-on-its-way-out'); + + const { result } = renderHook(() => useStoredItem(STORAGE_KEYS.LOGIN_TOKEN)); + + act(() => { + removeStoredItem(STORAGE_KEYS.LOGIN_TOKEN); + }); + + expect(result.current).toBeNull(); +}); + +// The other half of the same problem: a `storage` event is delivered only to the *other* tabs, so it is the only +// notice a window resuming a session gets that another tab has logged out from under it. +it('re-renders when another tab changes the value', () => { + localStorage.setItem(STORAGE_KEYS.LOGIN_TOKEN, 'a-token-another-tab-drops'); + + const { result } = renderHook(() => useStoredItem(STORAGE_KEYS.LOGIN_TOKEN)); + + act(() => { + // jsdom does not raise `storage` for writes made in the same window — which is right, and why the same-tab + // path needs `notify` — so stand in for the other tab by removing the value and raising the event by hand. + localStorage.removeItem(STORAGE_KEYS.LOGIN_TOKEN); + window.dispatchEvent( + new StorageEvent('storage', { key: STORAGE_KEYS.LOGIN_TOKEN, oldValue: 'a-token-another-tab-drops', newValue: null }), + ); + }); + + expect(result.current).toBeNull(); +}); + +it('lets go of the storage listener with its last subscriber', () => { + const addEventListener = jest.spyOn(window, 'addEventListener'); + const removeEventListener = jest.spyOn(window, 'removeEventListener'); + + const { unmount } = renderHook(() => useStoredItem(STORAGE_KEYS.LOGIN_TOKEN)); + + expect(addEventListener).toHaveBeenCalledWith('storage', expect.any(Function)); + + unmount(); + + expect(removeEventListener).toHaveBeenCalledWith('storage', expect.any(Function)); + + addEventListener.mockRestore(); + removeEventListener.mockRestore(); +}); + +it('ignores a write to another key', () => { + localStorage.setItem(STORAGE_KEYS.LOGIN_TOKEN, 'a-stored-token'); + + const renders = jest.fn(); + renderHook(() => { + renders(); + return useStoredItem(STORAGE_KEYS.LOGIN_TOKEN); + }); + + const rendersBefore = renders.mock.calls.length; + + act(() => { + setStoredItem(STORAGE_KEYS.USER_ID, 'john.doe'); + }); + + expect(renders).toHaveBeenCalledTimes(rendersBefore); +}); diff --git a/apps/meteor/client/hooks/useStoredItem.ts b/apps/meteor/client/hooks/useStoredItem.ts new file mode 100644 index 0000000000000..49352ab490703 --- /dev/null +++ b/apps/meteor/client/hooks/useStoredItem.ts @@ -0,0 +1,24 @@ +import { useCallback, useSyncExternalStore } from 'react'; + +import type { StorageKey } from '../lib/sdk/storage'; +import { getStoredItem, subscribeStoredItem } from '../lib/sdk/storage'; + +const getServerSnapshot = (): string | null => null; + +/** + * Reads a value out of the client-side persistent storage, and re-renders when it changes. + * + * `getStoredItem` on its own is a plain `localStorage` read: a component that calls it during render picks up a + * write only if something *else* it subscribes to changes at the same time. Where the stored value is what + * decides the render — `AuthenticationCheck` reading the login token — that coincidence is not something to + * depend on, hence this. + * + * The snapshot is the stored string itself, so React's `Object.is` bail-out compares it by value and a `notify` + * for some other key costs nothing beyond the read. + */ +export const useStoredItem = (key: StorageKey): string | null => + useSyncExternalStore( + subscribeStoredItem, + useCallback(() => getStoredItem(key), [key]), + getServerSnapshot, + ); diff --git a/apps/meteor/client/lib/sdk/storage.ts b/apps/meteor/client/lib/sdk/storage.ts index b2aa37dd3d5bf..bcc691a176a78 100644 --- a/apps/meteor/client/lib/sdk/storage.ts +++ b/apps/meteor/client/lib/sdk/storage.ts @@ -32,11 +32,63 @@ const getStorage = (): Storage | undefined => { return getStorageForBackend(storageBackend); }; +/** + * A same-tab write to `localStorage` announces itself to nobody: the DOM `storage` event is only ever delivered + * to the *other* tabs. Anything that renders from a stored value — the session-resume gate in + * `AuthenticationCheck` above all — therefore has to be told, or it reads a stale value and keeps it until some + * unrelated state change happens to render it again. Every mutation below goes through `notify`, and + * `subscribeStoredItem` is what `useSyncExternalStore` consumers hook into. + * + * The `storage` event is the other half of that, and covers exactly the writes `notify` cannot see: a logout in + * another tab takes the token out from under a window that is still resuming, and that window has no other way + * to hear about it. `ensureConnectedAndAuthenticated` reads the token only once it has a connection, so a token + * that left in the meantime makes it return without a user, without clearing anything, and without a resume to + * fail — leaving a gate that never re-renders sitting on a token that is no longer there. + */ +const listeners = new Set<() => void>(); + +const notify = (): void => { + for (const listener of listeners) { + listener(); + } +}; + +/** + * Attached with the first subscriber and dropped with the last, so imperative readers pay nothing for it. The + * event is not filtered by key or by storage area: `notify` is keyless anyway, and a subscriber that reads a + * key nobody touched re-reads an unchanged snapshot, which React discards. + */ +const handleStorageEvent = (): void => { + notify(); +}; + +export const subscribeStoredItem = (listener: () => void): (() => void) => { + if (listeners.size === 0 && typeof window !== 'undefined') { + window.addEventListener('storage', handleStorageEvent); + } + + listeners.add(listener); + + return () => { + listeners.delete(listener); + + if (listeners.size === 0 && typeof window !== 'undefined') { + window.removeEventListener('storage', handleStorageEvent); + } + }; +}; + export const getStoredItem = (key: StorageKey): string | null => getStorage()?.getItem(key) ?? null; -export const setStoredItem = (key: StorageKey, value: string): void => getStorage()?.setItem(key, value); +export const setStoredItem = (key: StorageKey, value: string): void => { + getStorage()?.setItem(key, value); + notify(); +}; -export const removeStoredItem = (key: StorageKey): void => getStorage()?.removeItem(key); +export const removeStoredItem = (key: StorageKey): void => { + getStorage()?.removeItem(key); + notify(); +}; let storageBackend: StorageBackend = 'local'; @@ -50,6 +102,7 @@ export const setStorageBackend = (backend: StorageBackend): boolean => { } storageBackend = backend; + notify(); return true; }; diff --git a/apps/meteor/client/views/root/MainLayout/AuthenticationCheck.spec.tsx b/apps/meteor/client/views/root/MainLayout/AuthenticationCheck.spec.tsx new file mode 100644 index 0000000000000..d1669e205445e --- /dev/null +++ b/apps/meteor/client/views/root/MainLayout/AuthenticationCheck.spec.tsx @@ -0,0 +1,173 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import type { AuthenticationContextValue, SessionContextValue } from '@rocket.chat/ui-contexts'; +import { AuthenticationContext, SessionContext, UserContext } from '@rocket.chat/ui-contexts'; +import { act, render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { useContext } from 'react'; + +import AuthenticationCheck from './AuthenticationCheck'; +import { STORAGE_KEYS, removeStoredItem } from '../../../lib/sdk/storage'; + +// The chain below this gate reaches for a great deal of app; what this spec is about is which of the three +// outcomes the gate picks, so stand the rest of it down. +jest.mock('./LoggedInArea', () => ({ __esModule: true, default: ({ children }: { children: ReactNode }) => <>{children} })); +jest.mock('./UsernameCheck', () => ({ __esModule: true, default: ({ children }: { children: ReactNode }) => <>{children} })); +jest.mock('./LoginPage', () => ({ __esModule: true, default: () =>
login-page
})); +jest.mock('../../home/HomeSkeleton', () => ({ __esModule: true, default: () =>
home-skeleton
})); + +const renderGate = (root: ReturnType) => + render(conference, { wrapper: root.build() }); + +afterEach(() => { + localStorage.clear(); +}); + +it('renders the route for a user who is logged in', () => { + renderGate(mockAppRoot().withJohnDoe()); + + expect(screen.getByText('conference')).toBeInTheDocument(); +}); + +it('shows the login page to a visitor with no session', () => { + renderGate(mockAppRoot().withAnonymous()); + + expect(screen.getByText('login-page')).toBeInTheDocument(); +}); + +// The bug this guards: a window that opens with a session already stored — a call popout, or any plain reload — +// has no user until the login is resumed from that token, and showed a login form in the meantime. +describe('while the session is being resumed', () => { + it('shows the skeleton instead of the login page when a stored token is about to be used', () => { + localStorage.setItem(STORAGE_KEYS.LOGIN_TOKEN, 'a-stored-token'); + + renderGate(mockAppRoot().withAnonymous()); + + expect(screen.queryByText('login-page')).not.toBeInTheDocument(); + expect(screen.getByText('home-skeleton')).toBeInTheDocument(); + }); + + // The stored token is read through a subscription, not per render, and this is why: a rejected resume clears + // the credentials without anything else about the gate changing — no user arrives, the status stays healthy — + // so a per-render read would sit on the stale token with no next render to correct it. + it('falls through to the login page when the stored token is cleared under it', () => { + localStorage.setItem(STORAGE_KEYS.LOGIN_TOKEN, 'a-token-the-server-rejects'); + + renderGate(mockAppRoot().withAnonymous()); + + expect(screen.getByText('home-skeleton')).toBeInTheDocument(); + + act(() => { + removeStoredItem(STORAGE_KEYS.LOGIN_TOKEN); + }); + + expect(screen.getByText('login-page')).toBeInTheDocument(); + expect(screen.queryByText('home-skeleton')).not.toBeInTheDocument(); + }); + + // The one way a stored token could strand someone: it is cleared when a server *rejects* it, so a server that + // never answers at all clears nothing. Once the connection stops trying, the form has to be reachable. + it.each(['waiting', 'failed'] as const)('shows the login page when the connection has given up (%s)', (status) => { + localStorage.setItem(STORAGE_KEYS.LOGIN_TOKEN, 'a-stored-token'); + + renderGate(mockAppRoot().withAnonymous().withServerContext({ connected: false, status })); + + expect(screen.getByText('login-page')).toBeInTheDocument(); + expect(screen.queryByText('home-skeleton')).not.toBeInTheDocument(); + }); + + // `offline` is not a give-up state, however much it reads like one: the DDP SDK starts every page load `idle` + // and that is reported as `offline`, so counting it would show the form on healthy reloads — the very bug. + it('keeps the skeleton up while the connection is merely idle', () => { + localStorage.setItem(STORAGE_KEYS.LOGIN_TOKEN, 'a-stored-token'); + + renderGate(mockAppRoot().withAnonymous().withServerContext({ connected: false, status: 'offline' })); + + expect(screen.getByText('home-skeleton')).toBeInTheDocument(); + expect(screen.queryByText('login-page')).not.toBeInTheDocument(); + }); + + // ...but not while it is still the ordinary first connect of a page load, which is the flash this removes. + it('keeps the skeleton up while the connection is still being made', () => { + localStorage.setItem(STORAGE_KEYS.LOGIN_TOKEN, 'a-stored-token'); + + renderGate(mockAppRoot().withAnonymous().withServerContext({ connected: false, status: 'connecting' })); + + expect(screen.getByText('home-skeleton')).toBeInTheDocument(); + expect(screen.queryByText('login-page')).not.toBeInTheDocument(); + }); + + // Someone who was logged out, or whose session the server rejected, must reach the form — otherwise a stale + // token in storage would leave them staring at a skeleton forever. The mocked app root has no session + // support, so this one supplies the context itself. + it('shows the login page when a login is being forced', () => { + localStorage.setItem(STORAGE_KEYS.LOGIN_TOKEN, 'a-stale-token'); + + const forcingLogin = { + query: () => [() => () => undefined, () => true] as ReturnType, + dispatch: () => undefined, + }; + + renderGate( + mockAppRoot() + .withAnonymous() + .wrap((children) => {children}), + ); + + expect(screen.getByText('login-page')).toBeInTheDocument(); + }); +}); + +// The regression that came with the guard above, and the reason it asks only about the stored token: `isLoggingIn` +// is true of *any* login in flight, a person typing their password at the form included. Swapping the form for a +// skeleton mid-attempt lost the rejection — the form came back blank, with neither field marked invalid — and +// iframe login never appeared at all, since the flow that fetches its URL runs from inside `LoginPage`. +it('keeps the login page up while someone is logging in at it', () => { + const loggingIn = { isLoggingIn: true } as AuthenticationContextValue; + + renderGate( + mockAppRoot() + .withAnonymous() + .wrap((children) => {children}), + ); + + expect(screen.getByText('login-page')).toBeInTheDocument(); + expect(screen.queryByText('home-skeleton')).not.toBeInTheDocument(); +}); + +// Overrides whatever user the mocked root supplies, without changing the shape of the tree — the gate has to keep +// the same instance across the switch for the test to be about anything. +const MaybeSessionEnded = ({ ended, children }: { ended: boolean; children: ReactNode }) => { + const value = useContext(UserContext); + + return {children}; +}; + +// Deleting your own account ends the session server-side but clears nothing locally: the stored token stays put, +// and reading it alone the gate answered "a resume is in flight" and held the skeleton up for good. A user that +// has already been seen going away is an ended session, whatever storage still says. +it('shows the login page when the user goes away with a token still stored', () => { + localStorage.setItem(STORAGE_KEYS.LOGIN_TOKEN, 'a-token-nobody-cleared'); + + const Root = mockAppRoot().withJohnDoe().build(); + + const { rerender } = render( + + + conference + + , + ); + + expect(screen.getByText('conference')).toBeInTheDocument(); + + rerender( + + + conference + + , + ); + + expect(screen.getByText('login-page')).toBeInTheDocument(); + expect(screen.queryByText('home-skeleton')).not.toBeInTheDocument(); +}); diff --git a/apps/meteor/client/views/root/MainLayout/AuthenticationCheck.tsx b/apps/meteor/client/views/root/MainLayout/AuthenticationCheck.tsx index 41e0d9bdb6d7f..37276ae272000 100644 --- a/apps/meteor/client/views/root/MainLayout/AuthenticationCheck.tsx +++ b/apps/meteor/client/views/root/MainLayout/AuthenticationCheck.tsx @@ -1,10 +1,14 @@ -import { useSession, useUser, useSetting } from '@rocket.chat/ui-contexts'; +import { useConnectionStatus, useSession, useUser, useSetting } from '@rocket.chat/ui-contexts'; import RegistrationRoute from '@rocket.chat/web-ui-registration'; import type { ReactNode } from 'react'; +import { useEffect, useRef, useState } from 'react'; import LoggedInArea from './LoggedInArea'; import LoginPage from './LoginPage'; import UsernameCheck from './UsernameCheck'; +import { useStoredItem } from '../../../hooks/useStoredItem'; +import { STORAGE_KEYS } from '../../../lib/sdk/storage'; +import HomeSkeleton from '../../home/HomeSkeleton'; /* * Anonymous and guest are similar in some way @@ -17,11 +21,81 @@ import UsernameCheck from './UsernameCheck'; */ export type AuthenticationCheckProps = { children: ReactNode; guest?: boolean }; +/** + * The connection states that mean a server was reached for and lost, as opposed to not having answered yet. + * + * `offline` is deliberately not one of them, however much it sounds like the plainest case: the DDP SDK begins + * every page load `idle`, and `sdkStatusToMeteor` reports `idle` as `offline`. Counting it would give up on the + * connection before it had been attempted, on exactly the healthy reloads this exists to protect. + */ +const hasGivenUp = (status: ReturnType['status']): boolean => status === 'waiting' || status === 'failed'; + const AuthenticationCheck = ({ children, guest }: AuthenticationCheckProps) => { const user = useUser(); const allowAnonymousRead = useSetting('Accounts_AllowAnonymousRead'); const forceLogin = useSession('forceLogin'); + const { status } = useConnectionStatus(); + + /** + * A resume needs a server to answer it, and waiting on one that never will is the one way this can strand + * someone: the token is only cleared when a server *rejects* it, so an unreachable server — a dropped network, + * a captive portal, a workspace that is down — clears nothing and would leave the skeleton up for good. + * + * Which states count is the whole of the care here — see `hasGivenUp`, and note that neither `connecting` nor + * `offline` is one of them. Once a state that counts has been seen it stands: a retry flapping between + * `waiting` and `connecting` must not flap the form back into a skeleton. A resume that succeeds later brings + * a user with it, which wins anyway. + * + * Seeded from the status rather than latched from `false`, so a mount that is *already* offline picks the form + * on its first render instead of showing a frame of skeleton on the way to it. + */ + const [unreachable, setUnreachable] = useState(() => hasGivenUp(status)); + + useEffect(() => { + if (hasGivenUp(status)) { + setUnreachable(true); + } + }, [status]); + + /** + * A resume is something that happens *before* the first user of a mount, never after one. Once a user has been + * seen, a user that goes away again is a session that ended — a logout, an account deleted from under itself, + * a token the server revoked — and the form, not a skeleton, is the answer. Latching this is what keeps the + * skeleton bounded by something the component owns, rather than by every path that drops a session + * remembering to clear the stored token on its way out: deleting your own account does not, and left the + * skeleton up for good. + */ + const hasSeenUser = useRef(false); + if (user) { + hasSeenUser.current = true; + } + + /** + * A window that opens with a session already stored — a call popout, or any plain reload — has no user until + * the login is resumed from that token. Treating "no user yet" as "not logged in" showed a login form for the + * few hundred milliseconds it took, to someone who never asked for one. + * + * The stored token is the whole of the test, and deliberately so. It is written before the window loads and + * removed on an explicit logout or a rejected resume, so it covers the resume from end to end. Asking + * `isLoggingIn` as well looked like it covered the same ground more directly, but it is true of *any* login in + * flight, including one someone is making at the form right now: that unmounted the form mid-attempt, so a + * rejected password came back to a blank form with nothing marked invalid, and iframe login — which runs from + * inside `LoginPage` — could never get as far as showing its own form at all. + * + * Subscribed to rather than read per render, because the writes that matter here are same-tab ones and those + * announce themselves to nobody: `clearStoredCredentials()` ends by nulling a connection userId that is + * *already* null on a resume that never got a user, so neither the Tracker dep nor the userId store fires and + * there is no next render to fall through on. See `useStoredItem`. + */ + const loginToken = useStoredItem(STORAGE_KEYS.LOGIN_TOKEN); + + const isResumingSession = !user && !hasSeenUser.current && !forceLogin && !unreachable && !!loginToken; + + if (isResumingSession) { + return ; + } + if (user) { return (