Explorar o código

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

IDE page: connection manager and loading screen

GitOrigin-RevId: 2cbc8c09aeb36a14eae66da78b267c7a830fb71a
Tim Down %!s(int64=2) %!d(string=hai) anos
pai
achega
719da5fbd8

+ 14 - 0
package-lock.json

@@ -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",

+ 2 - 0
services/web/app/views/project/editor/meta.pug

@@ -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)

+ 7 - 0
services/web/app/views/project/ide-react.pug

@@ -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

+ 11 - 7
services/web/frontend/js/features/ide-react/components/ide-root.tsx

@@ -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)

+ 21 - 2
services/web/frontend/js/features/ide-react/components/layout/ide-page.tsx

@@ -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>
+    </>
   )
 }

+ 69 - 0
services/web/frontend/js/features/ide-react/components/loading.tsx

@@ -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>
+  )
+}

+ 341 - 0
services/web/frontend/js/features/ide-react/connection/connection-manager.ts

@@ -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()
+    }
+  }
+}

+ 18 - 0
services/web/frontend/js/features/ide-react/connection/types/connection-state.ts

@@ -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
+}

+ 26 - 0
services/web/frontend/js/features/ide-react/connection/types/socket.ts

@@ -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
+}

+ 6 - 0
services/web/frontend/js/features/ide-react/connection/utils.ts

@@ -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
+}

+ 103 - 0
services/web/frontend/js/features/ide-react/context/connection-context.tsx

@@ -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
+}

+ 6 - 0
services/web/frontend/js/features/ide-react/context/react-context-root.tsx

@@ -0,0 +1,6 @@
+import { ConnectionProvider } from './connection-context'
+import { FC } from 'react'
+
+export const ReactContextRoot: FC = ({ children }) => {
+  return <ConnectionProvider>{children}</ConnectionProvider>
+}

+ 8 - 0
services/web/frontend/js/pages/ide.jsx

@@ -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)
 }

+ 24 - 14
services/web/frontend/js/shared/components/loading-branded.tsx

@@ -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>
   )
 }

+ 2 - 2
services/web/frontend/js/shared/context/root-context.jsx

@@ -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 - 1
services/web/frontend/stylesheets/app/editor/ide-react.less

@@ -1,4 +1,4 @@
-#ide-react-page {
+#ide-root {
   height: 100vh;
 }
 

+ 1 - 0
services/web/locales/en.json

@@ -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",

+ 1 - 0
services/web/package.json

@@ -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",

+ 11 - 11
services/web/test/frontend/helpers/editor-providers.jsx

@@ -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