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