mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
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:
parent
22531969f6
commit
719da5fbd8
19 changed files with 672 additions and 37 deletions
14
package-lock.json
generated
14
package-lock.json
generated
|
@ -38635,6 +38635,12 @@
|
|||
"queue-tick": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/strict-event-emitter": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz",
|
||||
"integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
|
@ -44101,6 +44107,7 @@
|
|||
"sinon-chai": "^3.7.0",
|
||||
"sinon-mongoose": "^2.3.0",
|
||||
"socket.io-mock": "^1.3.1",
|
||||
"strict-event-emitter": "^0.5.1",
|
||||
"terser-webpack-plugin": "^5.3.9",
|
||||
"timekeeper": "^2.2.0",
|
||||
"to-string-loader": "^1.2.0",
|
||||
|
@ -52417,6 +52424,7 @@
|
|||
"sinon-chai": "^3.7.0",
|
||||
"sinon-mongoose": "^2.3.0",
|
||||
"socket.io-mock": "^1.3.1",
|
||||
"strict-event-emitter": "^0.5.1",
|
||||
"terser-webpack-plugin": "^5.3.9",
|
||||
"timekeeper": "^2.2.0",
|
||||
"to-string-loader": "^1.2.0",
|
||||
|
@ -76519,6 +76527,12 @@
|
|||
"queue-tick": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"strict-event-emitter": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz",
|
||||
"integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==",
|
||||
"dev": true
|
||||
},
|
||||
"string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
|
|
|
@ -42,6 +42,8 @@ meta(name="ol-mathJax3Path" content=mathJax3Path)
|
|||
meta(name="ol-completedTutorials", data-type="json" content=user.completedTutorials)
|
||||
meta(name="ol-projectTags" data-type="json" content=projectTags)
|
||||
meta(name="ol-idePageReact", data-type="boolean" content=idePageReact)
|
||||
meta(name="ol-loadingText", data-type="string" content=translate("loading"))
|
||||
meta(name="ol-translationLoadErrorMessage", data-type="string" content=translate("could_not_load_translations"))
|
||||
|
||||
- var fileActionI18n = ['edited', 'renamed', 'created', 'deleted'].reduce((acc, i) => {acc[i] = translate('file_action_' + i); return acc}, {})
|
||||
meta(name="ol-fileActionI18n" data-type="json" content=fileActionI18n)
|
||||
|
|
|
@ -11,6 +11,13 @@ block entrypointVar
|
|||
|
||||
block content
|
||||
main#ide-root
|
||||
.loading-screen
|
||||
.loading-screen-brand-container
|
||||
.loading-screen-brand(style="height: 20%;")
|
||||
h3.loading-screen-label #{translate("loading")}
|
||||
span.loading-screen-ellip .
|
||||
span.loading-screen-ellip .
|
||||
span.loading-screen-ellip .
|
||||
|
||||
block append meta
|
||||
include ./editor/meta
|
||||
|
|
|
@ -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>
|
||||
}
|
|
@ -9,5 +9,13 @@ import IdeRoot from '../features/ide-react/components/ide-root'
|
|||
|
||||
const element = document.getElementById('ide-root')
|
||||
if (element) {
|
||||
// Remove loading screen provided by the server and replace it with the same
|
||||
// screen rendered in React. Could use replaceChildren() instead but browser
|
||||
// support is relatively recent (arrived in Safari in 2020)
|
||||
element.textContent = ''
|
||||
|
||||
// This will not be valid in React 18, which has a new API. See
|
||||
// https://github.com/reactwg/react-18/discussions/5
|
||||
// https://react.dev/blog/2022/03/08/react-18-upgrade-guide#deprecations
|
||||
ReactDOM.render(<IdeRoot />, element)
|
||||
}
|
||||
|
|
|
@ -1,10 +1,16 @@
|
|||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type LoadingBrandedTypes = {
|
||||
loadProgress: number
|
||||
loadProgress: number // Percentage
|
||||
label?: string
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
export default function LoadingBranded({ loadProgress }: LoadingBrandedTypes) {
|
||||
export default function LoadingBranded({
|
||||
loadProgress,
|
||||
label,
|
||||
error,
|
||||
}: LoadingBrandedTypes) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="loading-screen-brand-container">
|
||||
|
@ -12,18 +18,22 @@ export default function LoadingBranded({ loadProgress }: LoadingBrandedTypes) {
|
|||
className="loading-screen-brand"
|
||||
style={{ height: `${loadProgress}%` }}
|
||||
/>
|
||||
<div className="h3 loading-screen-label" aria-live="polite">
|
||||
{t('loading')}
|
||||
<span className="loading-screen-ellip" aria-hidden="true">
|
||||
.
|
||||
</span>
|
||||
<span className="loading-screen-ellip" aria-hidden="true">
|
||||
.
|
||||
</span>
|
||||
<span className="loading-screen-ellip" aria-hidden="true">
|
||||
.
|
||||
</span>
|
||||
</div>
|
||||
{error ? (
|
||||
<p className="loading-screen-error">{error}</p>
|
||||
) : (
|
||||
<div className="h3 loading-screen-label" aria-live="polite">
|
||||
{label || t('loading')}
|
||||
<span className="loading-screen-ellip" aria-hidden="true">
|
||||
.
|
||||
</span>
|
||||
<span className="loading-screen-ellip" aria-hidden="true">
|
||||
.
|
||||
</span>
|
||||
<span className="loading-screen-ellip" aria-hidden="true">
|
||||
.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -8,11 +8,11 @@ import { LocalCompileProvider } from './local-compile-context'
|
|||
import { DetachCompileProvider } from './detach-compile-context'
|
||||
import { LayoutProvider } from './layout-context'
|
||||
import { DetachProvider } from './detach-context'
|
||||
import { ChatProvider } from '../../features/chat/context/chat-context'
|
||||
import { ChatProvider } from '@/features/chat/context/chat-context'
|
||||
import { ProjectProvider } from './project-context'
|
||||
import { SplitTestProvider } from './split-test-context'
|
||||
import { FileTreeDataProvider } from './file-tree-data-context'
|
||||
import { ProjectSettingsProvider } from '../../features/editor-left-menu/context/project-settings-context'
|
||||
import { ProjectSettingsProvider } from '@/features/editor-left-menu/context/project-settings-context'
|
||||
|
||||
export function ContextRoot({ children, ide }) {
|
||||
return (
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
#ide-react-page {
|
||||
#ide-root {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
|
|
|
@ -327,6 +327,7 @@
|
|||
"copy": "Copy",
|
||||
"copy_project": "Copy Project",
|
||||
"copying": "Copying",
|
||||
"could_not_load_translations": "Could not load translations",
|
||||
"country": "Country",
|
||||
"country_flag": "__country__ country flag",
|
||||
"coupon_code": "Coupon code",
|
||||
|
|
|
@ -330,6 +330,7 @@
|
|||
"sinon-chai": "^3.7.0",
|
||||
"sinon-mongoose": "^2.3.0",
|
||||
"socket.io-mock": "^1.3.1",
|
||||
"strict-event-emitter": "^0.5.1",
|
||||
"terser-webpack-plugin": "^5.3.9",
|
||||
"timekeeper": "^2.2.0",
|
||||
"to-string-loader": "^1.2.0",
|
||||
|
|
|
@ -2,17 +2,17 @@
|
|||
/* eslint-disable react/prop-types */
|
||||
import sinon from 'sinon'
|
||||
import { get } from 'lodash'
|
||||
import { SplitTestProvider } from '../../../frontend/js/shared/context/split-test-context'
|
||||
import { IdeProvider } from '../../../frontend/js/shared/context/ide-context'
|
||||
import { UserProvider } from '../../../frontend/js/shared/context/user-context'
|
||||
import { ProjectProvider } from '../../../frontend/js/shared/context/project-context'
|
||||
import { FileTreeDataProvider } from '../../../frontend/js/shared/context/file-tree-data-context'
|
||||
import { EditorProvider } from '../../../frontend/js/shared/context/editor-context'
|
||||
import { DetachProvider } from '../../../frontend/js/shared/context/detach-context'
|
||||
import { LayoutProvider } from '../../../frontend/js/shared/context/layout-context'
|
||||
import { LocalCompileProvider } from '../../../frontend/js/shared/context/local-compile-context'
|
||||
import { DetachCompileProvider } from '../../../frontend/js/shared/context/detach-compile-context'
|
||||
import { ProjectSettingsProvider } from '../../../frontend/js/features/editor-left-menu/context/project-settings-context'
|
||||
import { SplitTestProvider } from '@/shared/context/split-test-context'
|
||||
import { IdeProvider } from '@/shared/context/ide-context'
|
||||
import { UserProvider } from '@/shared/context/user-context'
|
||||
import { ProjectProvider } from '@/shared/context/project-context'
|
||||
import { FileTreeDataProvider } from '@/shared/context/file-tree-data-context'
|
||||
import { EditorProvider } from '@/shared/context/editor-context'
|
||||
import { DetachProvider } from '@/shared/context/detach-context'
|
||||
import { LayoutProvider } from '@/shared/context/layout-context'
|
||||
import { LocalCompileProvider } from '@/shared/context/local-compile-context'
|
||||
import { DetachCompileProvider } from '@/shared/context/detach-compile-context'
|
||||
import { ProjectSettingsProvider } from '@/features/editor-left-menu/context/project-settings-context'
|
||||
|
||||
// these constants can be imported in tests instead of
|
||||
// using magic strings
|
||||
|
|
Loading…
Reference in a new issue