diff --git a/package-lock.json b/package-lock.json
index 13853fe436..37e8fcbbd4 100644
--- a/package-lock.json
+++ b/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",
diff --git a/services/web/app/views/project/editor/meta.pug b/services/web/app/views/project/editor/meta.pug
index 9eb19aee12..fcadeb204d 100644
--- a/services/web/app/views/project/editor/meta.pug
+++ b/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)
diff --git a/services/web/app/views/project/ide-react.pug b/services/web/app/views/project/ide-react.pug
index d1f6aa675c..3d7b5213e6 100644
--- a/services/web/app/views/project/ide-react.pug
+++ b/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
diff --git a/services/web/frontend/js/features/ide-react/components/ide-root.tsx b/services/web/frontend/js/features/ide-react/components/ide-root.tsx
index 8619fb8601..e9cd964b4b 100644
--- a/services/web/frontend/js/features/ide-react/components/ide-root.tsx
+++ b/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
+ return (
+
+
+
+
+
+ )
}
export default withErrorBoundary(IdeRoot, GenericErrorBoundaryFallback)
diff --git a/services/web/frontend/js/features/ide-react/components/layout/ide-page.tsx b/services/web/frontend/js/features/ide-react/components/layout/ide-page.tsx
index c9d24bf061..795def99d6 100644
--- a/services/web/frontend/js/features/ide-react/components/layout/ide-page.tsx
+++ b/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 (
-
+ <>
+ {/* TODO: Alerts and left menu will go here */}
-
+ >
)
}
diff --git a/services/web/frontend/js/features/ide-react/components/loading.tsx b/services/web/frontend/js/features/ide-react/components/loading.tsx
new file mode 100644
index 0000000000..a65f33263a
--- /dev/null
+++ b/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 = {
+ 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('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}>
+ ) : (
+
+
+
+ )
+}
diff --git a/services/web/frontend/js/features/ide-react/connection/connection-manager.ts b/services/web/frontend/js/features/ide-react/connection/connection-manager.ts
new file mode 100644
index 0000000000..61edcab6c4
--- /dev/null
+++ b/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 {
+ 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()
+ }
+ }
+}
diff --git a/services/web/frontend/js/features/ide-react/connection/types/connection-state.ts b/services/web/frontend/js/features/ide-react/connection/types/connection-state.ts
new file mode 100644
index 0000000000..f9d2e1f853
--- /dev/null
+++ b/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
+}
diff --git a/services/web/frontend/js/features/ide-react/connection/types/socket.ts b/services/web/frontend/js/features/ide-react/connection/types/socket.ts
new file mode 100644
index 0000000000..c79cde2fa1
--- /dev/null
+++ b/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
+}
diff --git a/services/web/frontend/js/features/ide-react/connection/utils.ts b/services/web/frontend/js/features/ide-react/connection/utils.ts
new file mode 100644
index 0000000000..a9fd02ee56
--- /dev/null
+++ b/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
+}
diff --git a/services/web/frontend/js/features/ide-react/context/connection-context.tsx b/services/web/frontend/js/features/ide-react/context/connection-context.tsx
new file mode 100644
index 0000000000..a6b70fcd3b
--- /dev/null
+++ b/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(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(
+ () => ({
+ socket: connectionManager.socket,
+ connectionState,
+ isConnected,
+ isStillReconnecting,
+ secondsUntilReconnect,
+ tryReconnectNow,
+ registerUserActivity,
+ }),
+ [
+ connectionManager.socket,
+ connectionState,
+ isConnected,
+ isStillReconnecting,
+ registerUserActivity,
+ secondsUntilReconnect,
+ tryReconnectNow,
+ ]
+ )
+
+ return (
+
+ {children}
+
+ )
+}
+
+export function useConnectionContext(): ConnectionContextValue {
+ const context = useContext(ConnectionContext)
+
+ if (!context) {
+ throw new Error(
+ 'useConnectionContext is only available inside ConnectionProvider'
+ )
+ }
+
+ return context
+}
diff --git a/services/web/frontend/js/features/ide-react/context/react-context-root.tsx b/services/web/frontend/js/features/ide-react/context/react-context-root.tsx
new file mode 100644
index 0000000000..e580f5706b
--- /dev/null
+++ b/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 {children}
+}
diff --git a/services/web/frontend/js/pages/ide.jsx b/services/web/frontend/js/pages/ide.jsx
index d3eea73afc..ad000e06b3 100644
--- a/services/web/frontend/js/pages/ide.jsx
+++ b/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(, element)
}
diff --git a/services/web/frontend/js/shared/components/loading-branded.tsx b/services/web/frontend/js/shared/components/loading-branded.tsx
index cdae041492..bbd52c1914 100644
--- a/services/web/frontend/js/shared/components/loading-branded.tsx
+++ b/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 (
@@ -12,18 +18,22 @@ export default function LoadingBranded({ loadProgress }: LoadingBrandedTypes) {
className="loading-screen-brand"
style={{ height: `${loadProgress}%` }}
/>
-
- {t('loading')}
-
- .
-
-
- .
-
-
- .
-
-
+ {error ? (
+
{error}
+ ) : (
+
+ {label || t('loading')}
+
+ .
+
+
+ .
+
+
+ .
+
+
+ )}
)
}
diff --git a/services/web/frontend/js/shared/context/root-context.jsx b/services/web/frontend/js/shared/context/root-context.jsx
index a99a17e479..457fc36336 100644
--- a/services/web/frontend/js/shared/context/root-context.jsx
+++ b/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 (
diff --git a/services/web/frontend/stylesheets/app/editor/ide-react.less b/services/web/frontend/stylesheets/app/editor/ide-react.less
index 0aa7402696..51d611fa01 100644
--- a/services/web/frontend/stylesheets/app/editor/ide-react.less
+++ b/services/web/frontend/stylesheets/app/editor/ide-react.less
@@ -1,4 +1,4 @@
-#ide-react-page {
+#ide-root {
height: 100vh;
}
diff --git a/services/web/locales/en.json b/services/web/locales/en.json
index fff1b2c9e1..45694124a8 100644
--- a/services/web/locales/en.json
+++ b/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",
diff --git a/services/web/package.json b/services/web/package.json
index 5b5c38e490..49c835b27e 100644
--- a/services/web/package.json
+++ b/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",
diff --git a/services/web/test/frontend/helpers/editor-providers.jsx b/services/web/test/frontend/helpers/editor-providers.jsx
index 86dd70b611..9415d01919 100644
--- a/services/web/test/frontend/helpers/editor-providers.jsx
+++ b/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