Merge pull request #15056 from overleaf/td-ide-connection-load

IDE page: connection manager and loading screen

GitOrigin-RevId: 2cbc8c09aeb36a14eae66da78b267c7a830fb71a
This commit is contained in:
Tim Down
2023-10-17 08:03:13 +00:00
committed by Copybot
parent 22531969f6
commit 719da5fbd8
19 changed files with 672 additions and 37 deletions
@@ -1,7 +1,9 @@
import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n'
import { GenericErrorBoundaryFallback } from '@/shared/components/generic-error-boundary-fallback'
import withErrorBoundary from '@/infrastructure/error-boundary'
import IdePage from '@/features/ide-react/components/layout/ide-page'
import { ReactContextRoot } from '@/features/ide-react/context/react-context-root'
import { Loading } from '@/features/ide-react/components/loading'
import getMeta from '@/utils/meta'
function IdeRoot() {
// Check that we haven't inadvertently loaded Angular
@@ -10,13 +12,15 @@ function IdeRoot() {
throw new Error('Angular detected. This page must not load Angular.')
}
const { isReady } = useWaitForI18n()
const loadingText = getMeta('ol-loadingText')
if (!isReady) {
return null
}
return <IdePage />
return (
<ReactContextRoot>
<Loading loadingText={loadingText}>
<IdePage />
</Loading>
</ReactContextRoot>
)
}
export default withErrorBoundary(IdeRoot, GenericErrorBoundaryFallback)
@@ -1,11 +1,30 @@
import LayoutWithPlaceholders from '@/features/ide-react/components/layout/layout-with-placeholders'
import { useConnectionContext } from '@/features/ide-react/context/connection-context'
import useEventListener from '@/shared/hooks/use-event-listener'
import { useCallback, useEffect } from 'react'
// This is filled with placeholder content while the real content is migrated
// away from Angular
export default function IdePage() {
const { registerUserActivity } = useConnectionContext()
// Inform the connection manager when the user is active
const listener = useCallback(
() => registerUserActivity(),
[registerUserActivity]
)
useEventListener('cursor:editor:update', listener)
useEffect(() => {
document.body.addEventListener('click', listener)
return () => document.body.removeEventListener('click', listener)
}, [listener])
return (
<div id="ide-react-page">
<>
{/* TODO: Alerts and left menu will go here */}
<LayoutWithPlaceholders shouldPersistLayout />
</div>
</>
)
}
@@ -0,0 +1,69 @@
import { FC, useEffect, useState } from 'react'
import LoadingBranded from '../../../shared/components/loading-branded'
import i18n from '../../../i18n'
import { useConnectionContext } from '../context/connection-context'
import getMeta from '@/utils/meta'
type LoadStatus = 'initial' | 'rendered' | 'connected' | 'loaded'
const loadProgressPercentage: Record<LoadStatus, number> = {
initial: 20,
rendered: 40,
connected: 70,
loaded: 100,
}
// Pass in loading text from the server because i18n will not be ready initially
export const Loading: FC<{ loadingText: string }> = ({
loadingText,
children,
}) => {
const [loadStatus, setLoadStatus] = useState<LoadStatus>('initial')
const { connectionState, isConnected } = useConnectionContext()
const loadProgress = loadProgressPercentage[loadStatus]
const editorLoaded = loadStatus === 'loaded'
const [i18nLoaded, setI18nLoaded] = useState(false)
const [translationLoadError, setTranslationLoadError] = useState(false)
// Advance to 40% once this component is rendered
useEffect(() => {
// Force a reflow now so that the animation from 20% to 40% occurs
// eslint-disable-next-line no-void
void document.body.offsetHeight
setLoadStatus('rendered')
}, [])
useEffect(() => {
i18n
.then(() => setI18nLoaded(true))
.catch(() => {
setTranslationLoadError(true)
})
}, [])
useEffect(() => {
if (editorLoaded) {
return
}
if (isConnected) {
setLoadStatus(i18nLoaded ? 'loaded' : 'connected')
}
}, [i18nLoaded, editorLoaded, setLoadStatus, isConnected])
const translationLoadErrorMessage = translationLoadError
? getMeta('ol-translationLoadErrorMessage')
: ''
return editorLoaded ? (
<>{children}</>
) : (
<div className="loading-screen">
<LoadingBranded
loadProgress={loadProgress}
label={loadingText}
error={connectionState.error || translationLoadErrorMessage}
/>
</div>
)
}
@@ -0,0 +1,341 @@
import { ConnectionError, ConnectionState } from './types/connection-state'
import SocketIoShim from '../../../ide/connection/SocketIoShim'
import getMeta from '../../../utils/meta'
import { Emitter } from 'strict-event-emitter'
import { Socket } from '@/features/ide-react/connection/types/socket'
import { debugConsole } from '@/utils/debugging'
const ONE_HOUR_IN_MS = 1000 * 60 * 60
const TWO_MINUTES_IN_MS = 2 * 60 * 1000
const DISCONNECT_AFTER_MS = ONE_HOUR_IN_MS * 24
const CONNECTION_ERROR_RECONNECT_DELAY = 1000
const USER_ACTIVITY_RECONNECT_DELAY = 1000
const JOIN_PROJECT_RATE_LIMITED_DELAY = 15 * 1000
const RECONNECT_GRACEFULLY_RETRY_INTERVAL_MS = 5000
const MAX_RECONNECT_GRACEFULLY_INTERVAL_MS = 45 * 1000
const MAX_RETRY_CONNECT = 5
const initialState: ConnectionState = {
readyState: WebSocket.CLOSED,
forceDisconnected: false,
inactiveDisconnect: false,
lastConnectionAttempt: 0,
reconnectAt: null,
error: '',
}
type Events = {
statechange: [{ state: ConnectionState; previousState: ConnectionState }]
}
export class ConnectionManager extends Emitter<Events> {
state: ConnectionState = initialState
private connectionAttempt: number | null = null
private gracefullyReconnectUntil = 0
private lastUserActivity: number
private protocolVersion = -1
private readonly idleDisconnectInterval: number
private reconnectCountdownInterval = 0
readonly socket: Socket
constructor() {
super()
this.lastUserActivity = performance.now()
this.idleDisconnectInterval = window.setInterval(() => {
this.disconnectIfIdleSince(DISCONNECT_AFTER_MS)
}, ONE_HOUR_IN_MS)
window.addEventListener('online', this.onOnline)
const socket = SocketIoShim.connect('', {
'auto connect': false,
'connect timeout': 30 * 1000,
'force new connection': true,
query: new URLSearchParams({
projectId: getMeta('ol-project_id'),
}).toString(),
reconnect: false,
}) as unknown as Socket
this.socket = socket
socket.on('disconnect', () => this.onDisconnect())
socket.on('error', () => this.onConnectError())
socket.on('connect_failed', () => this.onConnectError())
socket.on('joinProjectResponse', body => this.onJoinProjectResponse(body))
socket.on('connectionRejected', err => this.onConnectionRejected(err))
socket.on('reconnectGracefully', () => this.onReconnectGracefully())
socket.on('forceDisconnect', (_, delay) => this.onForceDisconnect(delay))
this.tryReconnect()
}
close(error: ConnectionError) {
this.onForceDisconnect(0, error)
}
tryReconnectNow() {
this.tryReconnectWithBackoff(USER_ACTIVITY_RECONNECT_DELAY)
}
// Called when document is clicked or the editor cursor changes
registerUserActivity() {
this.lastUserActivity = performance.now()
this.ensureIsConnected()
}
private changeState(state: ConnectionState) {
const previousState = this.state
this.state = state
debugConsole.log('[ConnectionManager] changed state', {
previousState,
state,
})
this.emit('statechange', { state, previousState })
}
private onOnline() {
if (!this.state.inactiveDisconnect) this.ensureIsConnected()
}
private onConnectionRejected(err: any) {
switch (err?.message) {
case 'retry': // pending real-time shutdown
this.startAutoReconnectCountdown(0)
break
case 'rate-limit hit when joining project': // rate-limited
this.changeState({
...this.state,
error: 'rate-limited',
})
break
case 'not authorized': // not logged in
case 'invalid session': // expired session
this.changeState({
...this.state,
error: 'not-logged-in',
forceDisconnected: true,
})
break
case 'project not found': // project has been deleted
this.changeState({
...this.state,
error: 'project-deleted',
forceDisconnected: true,
})
break
default:
this.changeState({
...this.state,
error: 'unable-to-join',
})
break
}
}
private onConnectError() {
if (this.connectionAttempt === null) return // ignore errors once connected.
if (this.connectionAttempt++ < MAX_RETRY_CONNECT) {
setTimeout(
() => {
if (this.canReconnect()) this.socket.socket.connect()
},
// add jitter to spread reconnects
this.connectionAttempt *
(1 + Math.random()) *
CONNECTION_ERROR_RECONNECT_DELAY
)
} else {
this.disconnect()
this.changeState({
...this.state,
error: 'unable-to-connect',
})
}
}
private onDisconnect() {
this.connectionAttempt = null
this.changeState({
...this.state,
readyState: WebSocket.CLOSED,
})
if (this.disconnectIfIdleSince(DISCONNECT_AFTER_MS)) return
if (this.state.error === 'rate-limited') {
this.tryReconnectWithBackoff(JOIN_PROJECT_RATE_LIMITED_DELAY)
} else {
this.startAutoReconnectCountdown(0)
}
}
private onForceDisconnect(
delay: number,
error: ConnectionError = 'maintenance'
) {
clearInterval(this.idleDisconnectInterval)
clearTimeout(this.reconnectCountdownInterval)
window.removeEventListener('online', this.onOnline)
this.changeState({
...this.state,
forceDisconnected: true,
error,
})
setTimeout(() => this.disconnect(), delay * 1000)
}
private onJoinProjectResponse({
protocolVersion,
publicId,
}: {
protocolVersion: number
publicId: string
}) {
if (
this.protocolVersion !== -1 &&
this.protocolVersion !== protocolVersion
) {
this.onForceDisconnect(0, 'protocol-changed')
return
}
this.protocolVersion = protocolVersion
this.socket.publicId = publicId
this.connectionAttempt = null
this.changeState({
...this.state,
readyState: WebSocket.OPEN,
error: '',
reconnectAt: null,
})
}
private onReconnectGracefully() {
// Disconnect idle users a little earlier than the 24h limit.
if (this.disconnectIfIdleSince(DISCONNECT_AFTER_MS * 0.75)) return
if (this.gracefullyReconnectUntil) return
this.gracefullyReconnectUntil =
performance.now() + MAX_RECONNECT_GRACEFULLY_INTERVAL_MS
this.tryReconnectGracefully()
}
private canReconnect(): boolean {
if (this.state.readyState === WebSocket.OPEN) return false // no need to reconnect
if (this.state.forceDisconnected) return false // reconnecting blocked
return true
}
private isReconnectingSoon(ms: number): boolean {
if (!this.state.reconnectAt) return false
return this.state.reconnectAt - performance.now() <= ms
}
private hasReconnectedRecently(ms: number): boolean {
return performance.now() - this.state.lastConnectionAttempt < ms
}
private isUserInactiveSince(since: number): boolean {
return performance.now() - this.lastUserActivity > since
}
private disconnectIfIdleSince(threshold: number): boolean {
if (!this.isUserInactiveSince(threshold)) return false
const previouslyClosed = this.state.readyState === WebSocket.CLOSED
this.changeState({
...this.state,
readyState: WebSocket.CLOSED,
inactiveDisconnect: true,
})
if (!previouslyClosed) {
this.socket.disconnect()
}
return true
}
private disconnect() {
this.changeState({
...this.state,
readyState: WebSocket.CLOSED,
})
this.socket.disconnect()
}
private ensureIsConnected() {
if (this.state.readyState === WebSocket.OPEN) return
this.tryReconnectWithBackoff(
this.state.error === 'rate-limited'
? JOIN_PROJECT_RATE_LIMITED_DELAY
: USER_ACTIVITY_RECONNECT_DELAY
)
}
private startAutoReconnectCountdown(backoff: number) {
if (!this.canReconnect()) return
let countdown
if (this.isUserInactiveSince(TWO_MINUTES_IN_MS)) {
countdown = 60 + Math.floor(Math.random() * 2 * 60)
} else {
countdown = 3 + Math.floor(Math.random() * 7)
}
const ms = backoff + countdown * 1000
if (this.isReconnectingSoon(ms)) return
this.changeState({
...this.state,
reconnectAt: performance.now() + ms,
})
clearTimeout(this.reconnectCountdownInterval)
this.reconnectCountdownInterval = window.setTimeout(() => {
if (this.isReconnectingSoon(0)) {
this.tryReconnect()
}
}, ms)
}
private tryReconnect() {
this.gracefullyReconnectUntil = 0
this.changeState({
...this.state,
reconnectAt: null,
})
if (!this.canReconnect()) return
this.connectionAttempt = 0
this.changeState({
...this.state,
readyState: WebSocket.CONNECTING,
error: '',
inactiveDisconnect: false,
lastConnectionAttempt: performance.now(),
})
this.socket.socket.connect()
}
private tryReconnectGracefully() {
if (
this.state.readyState === WebSocket.CLOSED ||
!this.gracefullyReconnectUntil
)
return
if (
this.gracefullyReconnectUntil < performance.now() ||
this.isUserInactiveSince(RECONNECT_GRACEFULLY_RETRY_INTERVAL_MS)
) {
this.disconnect()
this.tryReconnect()
} else {
setTimeout(() => {
this.tryReconnectGracefully()
}, RECONNECT_GRACEFULLY_RETRY_INTERVAL_MS)
}
}
private tryReconnectWithBackoff(backoff: number) {
if (this.hasReconnectedRecently(backoff)) {
this.startAutoReconnectCountdown(backoff)
} else {
this.tryReconnect()
}
}
}
@@ -0,0 +1,18 @@
export type ConnectionError =
| 'maintenance'
| 'not-logged-in'
| 'out-of-sync'
| 'project-deleted'
| 'protocol-changed'
| 'rate-limited'
| 'unable-to-connect'
| 'unable-to-join'
export type ConnectionState = {
readyState: WebSocket['CONNECTING'] | WebSocket['OPEN'] | WebSocket['CLOSED']
forceDisconnected: boolean
inactiveDisconnect: boolean
reconnectAt: number | null
lastConnectionAttempt: number
error: '' | ConnectionError
}
@@ -0,0 +1,26 @@
export type Socket = {
publicId: string
on(event: string, callback: (...data: any[]) => void): void
emit(
event: string,
arg0: any,
callback?: (error: Error, ...data: any[]) => void
): void
emit(
event: string,
arg0: any,
arg1: any,
callback?: (error: Error, ...data: any[]) => void
): void
emit(
event: string,
arg0: any,
arg1: any,
arg2: any,
callback?: (error: Error, ...data: any[]) => void
): void
socket: {
connect(): void
}
disconnect(): void
}
@@ -0,0 +1,6 @@
export function secondsUntil(timestamp: number | null) {
if (!timestamp) return 0
const seconds = Math.ceil((timestamp - performance.now()) / 1000)
if (seconds > 0) return seconds
return 0
}
@@ -0,0 +1,103 @@
import {
createContext,
useContext,
useEffect,
useState,
FC,
useCallback,
useMemo,
} from 'react'
import { ConnectionState } from '../connection/types/connection-state'
import { ConnectionManager } from '@/features/ide-react/connection/connection-manager'
import { Socket } from '@/features/ide-react/connection/types/socket'
import { secondsUntil } from '@/features/ide-react/connection/utils'
type ConnectionContextValue = {
socket: Socket
connectionState: ConnectionState
isConnected: boolean
isStillReconnecting: boolean
secondsUntilReconnect: () => number
tryReconnectNow: () => void
registerUserActivity: () => void
}
const ConnectionContext = createContext<ConnectionContextValue | null>(null)
export const ConnectionProvider: FC = ({ children }) => {
const [connectionManager] = useState(() => new ConnectionManager())
const [connectionState, setConnectionState] = useState(
connectionManager.state
)
useEffect(() => {
function handleStateChange(event: { state: ConnectionState }) {
setConnectionState(event.state)
}
connectionManager.on('statechange', handleStateChange)
return () => {
connectionManager.off('statechange', handleStateChange)
}
}, [connectionManager])
const isConnected = connectionState.readyState === WebSocket.OPEN
const isStillReconnecting =
connectionState.readyState === WebSocket.CONNECTING &&
performance.now() - connectionState.lastConnectionAttempt > 1000
const secondsUntilReconnect = useCallback(
() => secondsUntil(connectionState.reconnectAt),
[connectionState.reconnectAt]
)
const tryReconnectNow = useCallback(
() => connectionManager.tryReconnectNow(),
[connectionManager]
)
const registerUserActivity = useCallback(
() => connectionManager.registerUserActivity(),
[connectionManager]
)
const value = useMemo<ConnectionContextValue>(
() => ({
socket: connectionManager.socket,
connectionState,
isConnected,
isStillReconnecting,
secondsUntilReconnect,
tryReconnectNow,
registerUserActivity,
}),
[
connectionManager.socket,
connectionState,
isConnected,
isStillReconnecting,
registerUserActivity,
secondsUntilReconnect,
tryReconnectNow,
]
)
return (
<ConnectionContext.Provider value={value}>
{children}
</ConnectionContext.Provider>
)
}
export function useConnectionContext(): ConnectionContextValue {
const context = useContext(ConnectionContext)
if (!context) {
throw new Error(
'useConnectionContext is only available inside ConnectionProvider'
)
}
return context
}
@@ -0,0 +1,6 @@
import { ConnectionProvider } from './connection-context'
import { FC } from 'react'
export const ReactContextRoot: FC = ({ children }) => {
return <ConnectionProvider>{children}</ConnectionProvider>
}