From 74c6085c55781d23c7659240f3544ded983593ae Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Tue, 24 Oct 2023 12:45:01 +0200 Subject: [PATCH] Merge pull request #15412 from overleaf/revert-15280-td-scope-store-and-emitter Revert "IDE scope store and emitter" GitOrigin-RevId: bb764c66830e5e30806609b2834f918cf7784dd7 --- .../connection/join-project-payload.ts | 14 - .../ide-react/connection/types/socket.ts | 1 - .../ide-react/context/ide-react-context.tsx | 135 ------- .../ide-react/context/react-context-root.tsx | 13 +- .../ide-react/create-ide-event-emitter.ts | 23 -- .../angular-scope-event-emitter.ts | 22 -- .../react-scope-event-emitter.ts | 26 -- .../angular-scope-value-store.ts | 28 -- .../react-scope-value-store.ts | 330 ------------------ .../ide-react/types/cursor-position.ts | 4 - .../ide-react/types/goto-line-options.ts | 5 - .../ide-react/types/permissions-level.ts | 1 - .../shared/context/ide-angular-provider.tsx | 16 - .../js/shared/context/ide-context.tsx | 37 +- .../js/shared/context/root-context.jsx | 7 +- .../shared/hooks/use-scope-event-emitter.ts | 13 +- .../shared/hooks/use-scope-event-listener.ts | 9 +- .../js/shared/hooks/use-scope-value.ts | 23 +- .../unit/react-scope-value-store.test.ts | 299 ---------------- .../frontend/helpers/editor-providers.jsx | 6 +- services/web/types/angular/scope.ts | 1 - services/web/types/ide/scope-event-emitter.ts | 15 - services/web/types/ide/scope-value-store.ts | 11 - 23 files changed, 38 insertions(+), 1001 deletions(-) delete mode 100644 services/web/frontend/js/features/ide-react/connection/join-project-payload.ts delete mode 100644 services/web/frontend/js/features/ide-react/context/ide-react-context.tsx delete mode 100644 services/web/frontend/js/features/ide-react/create-ide-event-emitter.ts delete mode 100644 services/web/frontend/js/features/ide-react/scope-event-emitter/angular-scope-event-emitter.ts delete mode 100644 services/web/frontend/js/features/ide-react/scope-event-emitter/react-scope-event-emitter.ts delete mode 100644 services/web/frontend/js/features/ide-react/scope-value-store/angular-scope-value-store.ts delete mode 100644 services/web/frontend/js/features/ide-react/scope-value-store/react-scope-value-store.ts delete mode 100644 services/web/frontend/js/features/ide-react/types/cursor-position.ts delete mode 100644 services/web/frontend/js/features/ide-react/types/goto-line-options.ts delete mode 100644 services/web/frontend/js/features/ide-react/types/permissions-level.ts delete mode 100644 services/web/frontend/js/shared/context/ide-angular-provider.tsx delete mode 100644 services/web/test/frontend/features/ide-react/unit/react-scope-value-store.test.ts delete mode 100644 services/web/types/angular/scope.ts delete mode 100644 services/web/types/ide/scope-event-emitter.ts delete mode 100644 services/web/types/ide/scope-value-store.ts diff --git a/services/web/frontend/js/features/ide-react/connection/join-project-payload.ts b/services/web/frontend/js/features/ide-react/connection/join-project-payload.ts deleted file mode 100644 index a06f80f20b..0000000000 --- a/services/web/frontend/js/features/ide-react/connection/join-project-payload.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Project } from '../../../../../types/project' -import { PermissionsLevel } from '../types/permissions-level' - -export type JoinProjectPayloadProject = Pick< - Project, - Exclude -> & { rootDoc_id?: string; publicAccesLevel?: string } - -export type JoinProjectPayload = { - permissionsLevel: PermissionsLevel - project: JoinProjectPayloadProject - protocolVersion: number - publicId: string -} 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 index 3f0cc97840..c79cde2fa1 100644 --- a/services/web/frontend/js/features/ide-react/connection/types/socket.ts +++ b/services/web/frontend/js/features/ide-react/connection/types/socket.ts @@ -1,7 +1,6 @@ export type Socket = { publicId: string on(event: string, callback: (...data: any[]) => void): void - removeListener(event: string, callback: (...data: any[]) => void): void emit( event: string, arg0: any, diff --git a/services/web/frontend/js/features/ide-react/context/ide-react-context.tsx b/services/web/frontend/js/features/ide-react/context/ide-react-context.tsx deleted file mode 100644 index 247019c302..0000000000 --- a/services/web/frontend/js/features/ide-react/context/ide-react-context.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { - createContext, - useContext, - useState, - FC, - useMemo, - useEffect, -} from 'react' -import { ReactScopeValueStore } from '@/features/ide-react/scope-value-store/react-scope-value-store' -import { IdeProvider } from '@/shared/context/ide-context' -import { - createIdeEventEmitter, - IdeEventEmitter, -} from '@/features/ide-react/create-ide-event-emitter' -import { JoinProjectPayload } from '@/features/ide-react/connection/join-project-payload' -import { useConnectionContext } from '@/features/ide-react/context/connection-context' -import { getMockIde } from '@/shared/context/mock/mock-ide' -import { ReactScopeEventEmitter } from '@/features/ide-react/scope-event-emitter/react-scope-event-emitter' - -type IdeReactContextValue = { - projectId: string - eventEmitter: IdeEventEmitter -} - -const IdeReactContext = createContext(null) - -function populateIdeReactScope(store: ReactScopeValueStore) { - store.set('sync_tex_error', false) -} - -function populateProjectScope(store: ReactScopeValueStore) { - store.allowNonExistentPath('project', true) - store.set('permissionsLevel', 'readOnly') -} - -function createReactScopeValueStore() { - const scopeStore = new ReactScopeValueStore() - - // Populate the scope value store with default values that will be used by - // nested contexts that refer to scope values. The ideal would be to leave - // initialization of store values up to the nested context, which would keep - // initialization code together with the context and would only populate - // necessary values in the store, but this is simpler for now - populateIdeReactScope(scopeStore) - populateProjectScope(scopeStore) - - return scopeStore -} - -const projectId = window.project_id - -export const IdeReactProvider: FC = ({ children }) => { - const [scopeStore] = useState(createReactScopeValueStore) - const [eventEmitter] = useState(createIdeEventEmitter) - const [scopeEventEmitter] = useState( - () => new ReactScopeEventEmitter(eventEmitter) - ) - - const { socket } = useConnectionContext() - - // Fire project:joined event - useEffect(() => { - function handleJoinProjectResponse({ - project, - permissionsLevel, - }: JoinProjectPayload) { - eventEmitter.emit('project:joined', { project, permissionsLevel }) - } - - socket.on('joinProjectResponse', handleJoinProjectResponse) - - return () => { - socket.removeListener('joinProjectResponse', handleJoinProjectResponse) - } - }, [socket, eventEmitter]) - - // Populate scope values when joining project, then fire project:joined event - useEffect(() => { - function handleJoinProjectResponse({ - project, - permissionsLevel, - }: JoinProjectPayload) { - scopeStore.set('project', { rootDoc_id: null, ...project }) - scopeStore.set('permissionsLevel', permissionsLevel) - // Make watchers update immediately - scopeStore.flushUpdates() - eventEmitter.emit('project:joined', { project, permissionsLevel }) - } - - socket.on('joinProjectResponse', handleJoinProjectResponse) - - return () => { - socket.removeListener('joinProjectResponse', handleJoinProjectResponse) - } - }, [socket, eventEmitter, scopeStore]) - - const ide = useMemo(() => { - return { - ...getMockIde(), - socket, - } - }, [socket]) - - const value = useMemo( - () => ({ - eventEmitter, - projectId, - }), - [eventEmitter] - ) - - return ( - - - {children} - - - ) -} - -export function useIdeReactContext(): IdeReactContextValue { - const context = useContext(IdeReactContext) - - if (!context) { - throw new Error( - 'useIdeReactContext is only available inside IdeReactProvider' - ) - } - - 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 index acd64d9cd3..e580f5706b 100644 --- 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 @@ -1,17 +1,6 @@ import { ConnectionProvider } from './connection-context' import { FC } from 'react' -import { IdeReactProvider } from '@/features/ide-react/context/ide-react-context' -import { ProjectProvider } from '@/shared/context/project-context' -import { UserProvider } from '@/shared/context/user-context' export const ReactContextRoot: FC = ({ children }) => { - return ( - - - - {children} - - - - ) + return {children} } diff --git a/services/web/frontend/js/features/ide-react/create-ide-event-emitter.ts b/services/web/frontend/js/features/ide-react/create-ide-event-emitter.ts deleted file mode 100644 index a8c8ef7131..0000000000 --- a/services/web/frontend/js/features/ide-react/create-ide-event-emitter.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Emitter } from 'strict-event-emitter' -import { Project } from '../../../../types/project' -import { PermissionsLevel } from '@/features/ide-react/types/permissions-level' -import { GotoLineOptions } from '@/features/ide-react/types/goto-line-options' -import { CursorPosition } from '@/features/ide-react/types/cursor-position' - -export type IdeEvents = { - 'project:joined': [{ project: Project; permissionsLevel: PermissionsLevel }] - - 'editor:gotoOffset': [gotoOffset: number] - 'editor:gotoLine': [options: GotoLineOptions] - 'outline-toggled': [isOpen: boolean] - 'cursor:editor:update': [position: CursorPosition] - 'cursor:editor:syncToPdf': [] - 'scroll:editor:update': [] - 'comment:start_adding': [] -} - -export type IdeEventEmitter = Emitter - -export function createIdeEventEmitter(): IdeEventEmitter { - return new Emitter() -} diff --git a/services/web/frontend/js/features/ide-react/scope-event-emitter/angular-scope-event-emitter.ts b/services/web/frontend/js/features/ide-react/scope-event-emitter/angular-scope-event-emitter.ts deleted file mode 100644 index 75173949bf..0000000000 --- a/services/web/frontend/js/features/ide-react/scope-event-emitter/angular-scope-event-emitter.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { - ScopeEventEmitter, - ScopeEventName, -} from '../../../../../types/ide/scope-event-emitter' -import { Scope } from '../../../../../types/angular/scope' - -export class AngularScopeEventEmitter implements ScopeEventEmitter { - // eslint-disable-next-line no-useless-constructor - constructor(readonly $scope: Scope) {} - - emit(eventName: ScopeEventName, broadcast: boolean, ...detail: unknown[]) { - if (broadcast) { - this.$scope.$broadcast(eventName, ...detail) - } else { - this.$scope.$emit(eventName, ...detail) - } - } - - on(eventName: ScopeEventName, listener: (...args: unknown[]) => void) { - return this.$scope.$on(eventName, listener) - } -} diff --git a/services/web/frontend/js/features/ide-react/scope-event-emitter/react-scope-event-emitter.ts b/services/web/frontend/js/features/ide-react/scope-event-emitter/react-scope-event-emitter.ts deleted file mode 100644 index 96cdb2987c..0000000000 --- a/services/web/frontend/js/features/ide-react/scope-event-emitter/react-scope-event-emitter.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { - ScopeEventEmitter, - ScopeEventName, -} from '../../../../../types/ide/scope-event-emitter' -import EventEmitter from 'events' - -export class ReactScopeEventEmitter implements ScopeEventEmitter { - // eslint-disable-next-line no-useless-constructor - constructor(private readonly eventEmitter: EventEmitter) {} - - emit(eventName: ScopeEventName, broadcast: boolean, ...detail: unknown[]) { - this.eventEmitter.emit(eventName, ...detail) - } - - on(eventName: ScopeEventName, listener: (...args: unknown[]) => void) { - // A listener attached via useScopeEventListener expects an event as the - // first parameter. We don't have one, so just provide an empty object - const wrappedListener = (...detail: unknown[]) => { - listener({}, ...detail) - } - this.eventEmitter.on(eventName, wrappedListener) - return () => { - this.eventEmitter.off(eventName, wrappedListener) - } - } -} diff --git a/services/web/frontend/js/features/ide-react/scope-value-store/angular-scope-value-store.ts b/services/web/frontend/js/features/ide-react/scope-value-store/angular-scope-value-store.ts deleted file mode 100644 index 237be90e58..0000000000 --- a/services/web/frontend/js/features/ide-react/scope-value-store/angular-scope-value-store.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { ScopeValueStore } from '../../../../../types/ide/scope-value-store' -import { Scope } from '../../../../../types/angular/scope' -import _ from 'lodash' - -export class AngularScopeValueStore implements ScopeValueStore { - // eslint-disable-next-line no-useless-constructor - constructor(readonly $scope: Scope) {} - - get(path: string) { - return _.get(this.$scope, path) - } - - set(path: string, value: unknown): void { - this.$scope.$applyAsync(() => _.set(this.$scope, path, value)) - } - - watch( - path: string, - callback: (newValue: T) => void, - deep: boolean - ): () => void { - return this.$scope.$watch( - path, - (newValue: T) => callback(deep ? _.cloneDeep(newValue) : newValue), - deep - ) - } -} diff --git a/services/web/frontend/js/features/ide-react/scope-value-store/react-scope-value-store.ts b/services/web/frontend/js/features/ide-react/scope-value-store/react-scope-value-store.ts deleted file mode 100644 index e61131ab80..0000000000 --- a/services/web/frontend/js/features/ide-react/scope-value-store/react-scope-value-store.ts +++ /dev/null @@ -1,330 +0,0 @@ -import { ScopeValueStore } from '../../../../../types/ide/scope-value-store' -import _ from 'lodash' -import customLocalStorage from '../../../infrastructure/local-storage' -import { debugConsole } from '@/utils/debugging' - -const NOT_FOUND = Symbol('not found') - -type Watcher = { - removed: boolean - callback: (value: T) => void -} - -// A value that has been set -type ScopeValueStoreValue = { - value?: T - watchers: Watcher[] -} - -type WatcherUpdate = { - path: string - value: T - watchers: Watcher[] -} - -type NonExistentValue = { - value: undefined -} - -type AllowedNonExistentPath = { - path: string - deep: boolean -} - -type Persister = { - localStorageKey: string - toPersisted?: (value: unknown) => unknown -} - -function isObject(value: unknown): value is object { - return ( - value !== null && - typeof value === 'object' && - !('length' in value && typeof value.length === 'number' && value.length > 0) - ) -} - -function ancestorPaths(path: string) { - const ancestors: string[] = [] - let currentPath = path - let lastPathSeparatorPos: number - while ((lastPathSeparatorPos = currentPath.lastIndexOf('.')) !== -1) { - currentPath = currentPath.slice(0, lastPathSeparatorPos) - ancestors.push(currentPath) - } - return ancestors -} - -// Store scope values in a simple map -export class ReactScopeValueStore implements ScopeValueStore { - private readonly items = new Map() - private readonly persisters: Map = new Map() - - private watcherUpdates: WatcherUpdate[] = [] - private watcherUpdateTimer: number | null = null - private allowedNonExistentPaths: AllowedNonExistentPath[] = [] - - private nonExistentPathAllowed(path: string) { - return this.allowedNonExistentPaths.some(allowedPath => { - return ( - allowedPath.path === path || - (allowedPath.deep && path.startsWith(allowedPath.path + '.')) - ) - }) - } - - // Create an item for a path. Attempt to get a value for the item from its - // ancestors, if there are any. - private findInAncestors(path: string): ScopeValueStoreValue { - // Populate value from the nested property ancestors, if possible - for (const ancestorPath of ancestorPaths(path)) { - const ancestorItem = this.items.get(ancestorPath) - if ( - ancestorItem && - 'value' in ancestorItem && - isObject(ancestorItem.value) - ) { - const pathRelativeToAncestor = path.slice(ancestorPath.length + 1) - const ancestorValue = _.get(ancestorItem.value, pathRelativeToAncestor) - if (ancestorValue !== NOT_FOUND) { - return { value: ancestorValue, watchers: [] } - } - } - } - return { watchers: [] } - } - - private getItem(path: string): ScopeValueStoreValue | NonExistentValue { - const item = this.items.get(path) || this.findInAncestors(path) - if (!('value' in item)) { - if (this.nonExistentPathAllowed(path)) { - debugConsole.log( - `No value found for key '${path}'. This is allowed because the path is in allowedNonExistentPaths` - ) - return { value: undefined } - } else { - throw new Error(`No value found for key '${path}'`) - } - } - return item - } - - private reassembleObjectValue(path: string, value: Record) { - const newValue: Record = { ...value } - const pathPrefix = path + '.' - for (const [key, item] of this.items.entries()) { - if (key.startsWith(pathPrefix)) { - const propName = key.slice(pathPrefix.length) - if (propName.indexOf('.') === -1 && 'value' in item) { - newValue[propName] = item.value - } - } - } - return newValue - } - - flushUpdates() { - if (this.watcherUpdateTimer) { - window.clearTimeout(this.watcherUpdateTimer) - this.watcherUpdateTimer = null - } - // Clone watcherUpdates in case a watcher creates new watcherUpdates - const watcherUpdates = [...this.watcherUpdates] - this.watcherUpdates = [] - for (const { value, watchers } of watcherUpdates) { - for (const watcher of watchers) { - if (!watcher.removed) { - watcher.callback.call(null, value) - } - } - } - } - - private scheduleWatcherUpdate( - path: string, - value: T, - watchers: Watcher[] - ) { - // Make a copy of the watchers so that any watcher added before this update - // runs is not triggered - const update: WatcherUpdate = { - value, - path, - watchers: [...watchers], - } - this.watcherUpdates.push(update) - if (!this.watcherUpdateTimer) { - this.watcherUpdateTimer = window.setTimeout(() => { - this.watcherUpdateTimer = null - this.flushUpdates() - }, 0) - } - } - - get(path: string) { - return this.getItem(path).value - } - - private setValue(path: string, value: T): void { - let item = this.items.get(path) - if (item === undefined) { - item = { value, watchers: [] } - this.items.set(path, item) - } else if (!('value' in item)) { - item = { ...item, value } - this.items.set(path, item) - } else if (item.value === value) { - // Don't update and trigger watchers if the value hasn't changed - return - } else { - item.value = value - } - this.scheduleWatcherUpdate(path, value, item.watchers) - - // Persist to local storage, if configured to do so - const persister = this.persisters.get(path) - if (persister) { - customLocalStorage.setItem( - persister.localStorageKey, - persister.toPersisted?.(value) || value - ) - } - } - - private setValueAndDescendants(path: string, value: T): void { - this.setValue(path, value) - - // Set nested values non-recursively, only updating existing items - if (isObject(value)) { - const pathPrefix = path + '.' - for (const [nestedPath, existingItem] of this.items.entries()) { - if (nestedPath.startsWith(pathPrefix)) { - const newValue = _.get( - value, - nestedPath.slice(pathPrefix.length), - NOT_FOUND - ) - // Only update a nested value if it has changed - if ( - newValue !== NOT_FOUND && - (!('value' in existingItem) || newValue !== existingItem.value) - ) { - this.setValue(nestedPath, newValue) - } - } - } - - // Delete nested items corresponding to properties that do not exist in - // the new object - const pathsToDelete: string[] = [] - const newPropNames = new Set(Object.keys(value)) - for (const path of this.items.keys()) { - if (path.startsWith(pathPrefix)) { - const propName = path.slice(pathPrefix.length).split('.', 1)[0] - if (!newPropNames.has(propName)) { - pathsToDelete.push(path) - } - } - } - for (const path of pathsToDelete) { - this.items.delete(path) - } - } - } - - set(path: string, value: unknown): void { - this.setValueAndDescendants(path, value) - - // Reassemble ancestors. For example, if the path is x.y.z, x.y and x have - // now changed too and must be updated - for (const ancestorPath of ancestorPaths(path)) { - const ancestorItem = this.items.get(ancestorPath) - if (ancestorItem && 'value' in ancestorItem) { - ancestorItem.value = this.reassembleObjectValue( - ancestorPath, - ancestorItem.value - ) - this.scheduleWatcherUpdate( - ancestorPath, - ancestorItem.value, - ancestorItem.watchers - ) - } - } - } - - // Watch for changes in a scope value. The value does not need to exist yet. - // Watchers are batched and called asynchronously to avoid chained state - // watcherUpdates, which result in warnings from React (see - // https://github.com/facebook/react/issues/18178) - watch(path: string, callback: Watcher['callback']): () => void { - let item = this.items.get(path) - if (!item) { - item = this.findInAncestors(path) - this.items.set(path, item) - } - const watchers = item.watchers - const watcher = { removed: false, callback } - item.watchers.push(watcher) - - // Schedule watcher immediately. This is to work around the fact that there - // is a delay between getting an initial value and adding a watcher in - // useScopeValue, during which the value could change without being - // observed - if ('value' in item) { - this.scheduleWatcherUpdate(path, item.value, [watcher]) - } - - return () => { - // Add a flag to the watcher so that it can be ignored if the watcher is - // removed in the interval between observing a change and being called - watcher.removed = true - _.pull(watchers, watcher) - } - } - - persisted( - path: string, - fallbackValue: Value, - localStorageKey: string, - converter?: { - toPersisted: (value: Value) => PersistedValue - fromPersisted: (persisted: PersistedValue) => Value - } - ) { - const persistedValue = customLocalStorage.getItem( - localStorageKey - ) as PersistedValue | null - - let value: Value = fallbackValue - if (persistedValue !== null) { - value = converter - ? converter.fromPersisted(persistedValue) - : (persistedValue as Value) - } - this.set(path, value) - - // Don't persist the value until set() is called - this.persisters.set(path, { - localStorageKey, - toPersisted: converter?.toPersisted as Persister['toPersisted'], - }) - } - - allowNonExistentPath(path: string, deep = false) { - this.allowedNonExistentPaths.push({ path, deep }) - } - - // For debugging - dump() { - const entries = [] - for (const [path, item] of this.items.entries()) { - entries.push({ - path, - value: 'value' in item ? item.value : '[not set]', - watcherCount: item.watchers.length, - }) - } - return entries - } -} diff --git a/services/web/frontend/js/features/ide-react/types/cursor-position.ts b/services/web/frontend/js/features/ide-react/types/cursor-position.ts deleted file mode 100644 index ed7f4138e1..0000000000 --- a/services/web/frontend/js/features/ide-react/types/cursor-position.ts +++ /dev/null @@ -1,4 +0,0 @@ -export type CursorPosition = { - row: number - column: number -} diff --git a/services/web/frontend/js/features/ide-react/types/goto-line-options.ts b/services/web/frontend/js/features/ide-react/types/goto-line-options.ts deleted file mode 100644 index 2fcd1d8417..0000000000 --- a/services/web/frontend/js/features/ide-react/types/goto-line-options.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface GotoLineOptions { - gotoLine: number - gotoColumn?: number - syncToPdf?: boolean -} diff --git a/services/web/frontend/js/features/ide-react/types/permissions-level.ts b/services/web/frontend/js/features/ide-react/types/permissions-level.ts deleted file mode 100644 index 43a6612653..0000000000 --- a/services/web/frontend/js/features/ide-react/types/permissions-level.ts +++ /dev/null @@ -1 +0,0 @@ -export type PermissionsLevel = 'owner' | 'readAndWrite' | 'readOnly' diff --git a/services/web/frontend/js/shared/context/ide-angular-provider.tsx b/services/web/frontend/js/shared/context/ide-angular-provider.tsx deleted file mode 100644 index a107f32ddf..0000000000 --- a/services/web/frontend/js/shared/context/ide-angular-provider.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { FC } from 'react' -import { AngularScopeValueStore } from '@/features/ide-react/scope-value-store/angular-scope-value-store' -import { AngularScopeEventEmitter } from '@/features/ide-react/scope-event-emitter/angular-scope-event-emitter' -import { Ide, IdeProvider } from '@/shared/context/ide-context' - -export const IdeAngularProvider: FC<{ ide: Ide }> = ({ ide, children }) => { - return ( - - {children} - - ) -} diff --git a/services/web/frontend/js/shared/context/ide-context.tsx b/services/web/frontend/js/shared/context/ide-context.tsx index 4ece0bf762..5a79cb69e0 100644 --- a/services/web/frontend/js/shared/context/ide-context.tsx +++ b/services/web/frontend/js/shared/context/ide-context.tsx @@ -1,41 +1,20 @@ -import { createContext, FC, useContext, useMemo } from 'react' -import { ScopeValueStore } from '../../../../types/ide/scope-value-store' -import { Scope } from '../../../../types/angular/scope' -import getMeta from '@/utils/meta' -import { ScopeEventEmitter } from '../../../../types/ide/scope-event-emitter' +import { createContext, FC, useContext, useState } from 'react' +import { getMockIde } from './mock/mock-ide' -export type Ide = { +type Ide = { [key: string]: any // TODO: define the rest of the `ide` and `$scope` properties - $scope: Scope + $scope: Record } -type IdeContextValue = Ide & { - isReactIde: boolean - scopeStore: ScopeValueStore - scopeEventEmitter: ScopeEventEmitter -} +const IdeContext = createContext(null) -const IdeContext = createContext(undefined) -const isReactIde: boolean = getMeta('ol-idePageReact') - -export const IdeProvider: FC<{ - ide: Ide - scopeStore: ScopeValueStore - scopeEventEmitter: ScopeEventEmitter -}> = ({ ide, scopeStore, scopeEventEmitter, children }) => { - const value = useMemo(() => { - return { - ...ide, - isReactIde, - scopeStore, - scopeEventEmitter, - } - }, [ide, scopeStore, scopeEventEmitter]) +export const IdeProvider: FC<{ ide: Ide }> = ({ ide, children }) => { + const [value] = useState(() => ide || getMockIde()) return {children} } -export function useIdeContext(): IdeContextValue { +export function useIdeContext(): Ide { const context = useContext(IdeContext) if (!context) { diff --git a/services/web/frontend/js/shared/context/root-context.jsx b/services/web/frontend/js/shared/context/root-context.jsx index e0e2f63022..457fc36336 100644 --- a/services/web/frontend/js/shared/context/root-context.jsx +++ b/services/web/frontend/js/shared/context/root-context.jsx @@ -2,7 +2,7 @@ import PropTypes from 'prop-types' import createSharedContext from 'react2angular-shared-context' import { UserProvider } from './user-context' -import { IdeAngularProvider } from './ide-angular-provider' +import { IdeProvider } from './ide-context' import { EditorProvider } from './editor-context' import { LocalCompileProvider } from './local-compile-context' import { DetachCompileProvider } from './detach-compile-context' @@ -13,12 +13,11 @@ 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 { getMockIde } from '@/shared/context/mock/mock-ide' export function ContextRoot({ children, ide }) { return ( - + @@ -38,7 +37,7 @@ export function ContextRoot({ children, ide }) { - + ) } diff --git a/services/web/frontend/js/shared/hooks/use-scope-event-emitter.ts b/services/web/frontend/js/shared/hooks/use-scope-event-emitter.ts index 788fce9ab4..dd66577187 100644 --- a/services/web/frontend/js/shared/hooks/use-scope-event-emitter.ts +++ b/services/web/frontend/js/shared/hooks/use-scope-event-emitter.ts @@ -1,17 +1,20 @@ import { useCallback } from 'react' import { useIdeContext } from '../context/ide-context' -import { ScopeEventName } from '../../../../types/ide/scope-event-emitter' export default function useScopeEventEmitter( - eventName: ScopeEventName, + eventName: string, broadcast = true ) { - const { scopeEventEmitter } = useIdeContext() + const { $scope } = useIdeContext() return useCallback( (...detail: unknown[]) => { - scopeEventEmitter.emit(eventName, broadcast, ...detail) + if (broadcast) { + $scope.$broadcast(eventName, ...detail) + } else { + $scope.$emit(eventName, ...detail) + } }, - [scopeEventEmitter, eventName, broadcast] + [$scope, eventName, broadcast] ) } diff --git a/services/web/frontend/js/shared/hooks/use-scope-event-listener.ts b/services/web/frontend/js/shared/hooks/use-scope-event-listener.ts index 1c3b59df12..cbc2d200c1 100644 --- a/services/web/frontend/js/shared/hooks/use-scope-event-listener.ts +++ b/services/web/frontend/js/shared/hooks/use-scope-event-listener.ts @@ -1,14 +1,13 @@ import { useEffect } from 'react' import { useIdeContext } from '../context/ide-context' -import { ScopeEventName } from '../../../../types/ide/scope-event-emitter' export default function useScopeEventListener( - eventName: ScopeEventName, + eventName: string, listener: (...args: unknown[]) => void ) { - const { scopeEventEmitter } = useIdeContext() + const { $scope } = useIdeContext() useEffect(() => { - return scopeEventEmitter.on(eventName, listener) - }, [scopeEventEmitter, eventName, listener]) + return $scope.$on(eventName, listener) + }, [$scope, eventName, listener]) } diff --git a/services/web/frontend/js/shared/hooks/use-scope-value.ts b/services/web/frontend/js/shared/hooks/use-scope-value.ts index 38a716a1eb..e3f3a50b75 100644 --- a/services/web/frontend/js/shared/hooks/use-scope-value.ts +++ b/services/web/frontend/js/shared/hooks/use-scope-value.ts @@ -12,39 +12,38 @@ import { useIdeContext } from '../context/ide-context' * Binds a property in an Angular scope making it accessible in a React * component. The interface is compatible with React.useState(), including * the option of passing a function to the setter. - * - * The generic type is not an actual guarantee because the value for a path is - * returned as undefined when there is nothing in the scope store for that path. */ export default function useScopeValue( path: string, // dot '.' path of a property in the Angular scope deep = false ): [T, Dispatch>] { - const { scopeStore } = useIdeContext() + const { $scope } = useIdeContext() - const [value, setValue] = useState(() => scopeStore.get(path)) + const [value, setValue] = useState(() => _.get($scope, path)) useEffect(() => { - return scopeStore.watch( + return $scope.$watch( path, (newValue: T) => { - // NOTE: this is deliberately wrapped in a function, - // to avoid calling setValue directly with a value that's a function - setValue(() => newValue) + setValue(() => { + // NOTE: this is deliberately wrapped in a function, + // to avoid calling setValue directly with a value that's a function + return deep ? _.cloneDeep(newValue) : newValue + }) }, deep ) - }, [path, scopeStore, deep]) + }, [path, $scope, deep]) const scopeSetter = useCallback( (newValue: SetStateAction) => { setValue(val => { const actualNewValue = _.isFunction(newValue) ? newValue(val) : newValue - scopeStore.set(path, actualNewValue) + $scope.$applyAsync(() => _.set($scope, path, actualNewValue)) return actualNewValue }) }, - [path, scopeStore] + [path, $scope] ) return [value, scopeSetter] diff --git a/services/web/test/frontend/features/ide-react/unit/react-scope-value-store.test.ts b/services/web/test/frontend/features/ide-react/unit/react-scope-value-store.test.ts deleted file mode 100644 index 4b3be365ae..0000000000 --- a/services/web/test/frontend/features/ide-react/unit/react-scope-value-store.test.ts +++ /dev/null @@ -1,299 +0,0 @@ -import { expect } from 'chai' -import { ReactScopeValueStore } from '@/features/ide-react/scope-value-store/react-scope-value-store' -import sinon from 'sinon' -import customLocalStorage from '@/infrastructure/local-storage' - -function waitForWatchers(callback: () => void) { - return new Promise(resolve => { - callback() - window.setTimeout(resolve, 1) - }) -} - -describe('ReactScopeValueStore', function () { - it('can set and retrieve a value', function () { - const store = new ReactScopeValueStore() - store.set('test', 'wombat') - const retrieved = store.get('test') - expect(retrieved).to.equal('wombat') - }) - - it('can overwrite a value', function () { - const store = new ReactScopeValueStore() - store.set('test', 'wombat') - store.set('test', 'not a wombat') - const retrieved = store.get('test') - expect(retrieved).to.equal('not a wombat') - }) - - it('can overwrite a nested value', function () { - const store = new ReactScopeValueStore() - store.set('test', { prop: 'wombat' }) - store.set('test.prop', 'not a wombat') - const retrieved = store.get('test.prop') - expect(retrieved).to.equal('not a wombat') - }) - - it('throws an error when retrieving an unknown value', function () { - const store = new ReactScopeValueStore() - expect(() => store.get('test')).to.throw - }) - - it('can watch a value', async function () { - const store = new ReactScopeValueStore() - store.set('changing', 'one') - store.set('fixed', 'one') - const changingItemWatcher = sinon.stub() - const fixedItemWatcher = sinon.stub() - await waitForWatchers(() => { - store.watch('changing', changingItemWatcher) - store.watch('fixed', fixedItemWatcher) - }) - expect(changingItemWatcher).to.have.been.calledWith('one') - expect(fixedItemWatcher).to.have.been.calledWith('one') - changingItemWatcher.reset() - fixedItemWatcher.reset() - await waitForWatchers(() => { - store.set('changing', 'two') - }) - expect(changingItemWatcher).to.have.been.calledWith('two') - expect(fixedItemWatcher).not.to.have.been.called - }) - - it('allows synchronous watcher updates', function () { - const store = new ReactScopeValueStore() - store.set('test', 'wombat') - const watcher = sinon.stub() - store.watch('test', watcher) - store.set('test', 'not a wombat') - expect(watcher).not.to.have.been.called - store.flushUpdates() - expect(watcher).to.have.been.calledWith('not a wombat') - }) - - it('removes a watcher', async function () { - const store = new ReactScopeValueStore() - store.set('test', 'wombat') - const watcher = sinon.stub() - const removeWatcher = store.watch('test', watcher) - store.flushUpdates() - watcher.reset() - removeWatcher() - store.set('test', 'not a wombat') - store.flushUpdates() - expect(watcher).not.to.have.been.called - }) - - it('does not call a watcher removed between observing change and being called', async function () { - const store = new ReactScopeValueStore() - store.set('test', 'wombat') - const watcher = sinon.stub() - const removeWatcher = store.watch('test', watcher) - store.flushUpdates() - watcher.reset() - store.set('test', 'not a wombat') - removeWatcher() - store.flushUpdates() - expect(watcher).not.to.have.been.called - }) - - it('does not trigger watcher on setting to an identical value', async function () { - const store = new ReactScopeValueStore() - store.set('test', 'wombat') - const watcher = sinon.stub() - await waitForWatchers(() => { - store.watch('test', watcher) - }) - expect(watcher).to.have.been.calledWith('wombat') - watcher.reset() - await waitForWatchers(() => { - store.set('test', 'wombat') - }) - expect(watcher).not.to.have.been.called - }) - - it('can watch a value before it has been set', async function () { - const store = new ReactScopeValueStore() - const watcher = sinon.stub() - store.watch('test', watcher) - await waitForWatchers(() => { - store.set('test', 'wombat') - }) - expect(watcher).to.have.been.calledWith('wombat') - }) - - it('throws an error when watching an unknown value', function () { - const store = new ReactScopeValueStore() - expect(() => store.watch('test', () => {})).to.throw - }) - - it('sets nested value if watched', function () { - const store = new ReactScopeValueStore() - store.set('test', { nested: 'one' }) - const watcher = sinon.stub() - store.watch('test.nested', watcher) - const retrieved = store.get('test.nested') - expect(retrieved).to.equal('one') - }) - - it('does not set nested value if not watched', function () { - const store = new ReactScopeValueStore() - store.set('test', { nested: 'one' }) - expect(() => store.get('test.nested')).to.throw - }) - - it('can watch a nested value', async function () { - const store = new ReactScopeValueStore() - store.set('test', { nested: 'one' }) - const watcher = sinon.stub() - store.watch('test.nested', watcher) - await waitForWatchers(() => { - store.set('test', { nested: 'two' }) - }) - expect(watcher).to.have.been.calledWith('two') - }) - - it('can watch a deeply nested value', async function () { - const store = new ReactScopeValueStore() - store.set('test', { levelOne: { levelTwo: { levelThree: 'one' } } }) - const watcher = sinon.stub() - store.watch('test.levelOne.levelTwo.levelThree', watcher) - await waitForWatchers(() => { - store.set('test', { levelOne: { levelTwo: { levelThree: 'two' } } }) - }) - expect(watcher).to.have.been.calledWith('two') - }) - - it('does not inform nested value watcher when nested value does not change', async function () { - const store = new ReactScopeValueStore() - store.set('test', { nestedOne: 'one', nestedTwo: 'one' }) - const nestedOneWatcher = sinon.stub() - const nestedTwoWatcher = sinon.stub() - await waitForWatchers(() => { - store.watch('test.nestedOne', nestedOneWatcher) - store.watch('test.nestedTwo', nestedTwoWatcher) - }) - nestedOneWatcher.reset() - nestedTwoWatcher.reset() - await waitForWatchers(() => { - store.set('test', { nestedOne: 'two', nestedTwo: 'one' }) - }) - expect(nestedOneWatcher).to.have.been.calledWith('two') - expect(nestedTwoWatcher).not.to.have.been.called - }) - - it('deletes nested values that no longer exist', function () { - const store = new ReactScopeValueStore() - store.set('test', { levelOne: { levelTwo: { levelThree: 'one' } } }) - store.set('test', { levelOne: { different: 'wombat' } }) - const retrieved = store.get('test.levelOne.different') - expect(retrieved).to.equal('wombat') - expect(() => store.get('test.levelOne.levelTwo')).to.throw - expect(() => store.get('test.levelOne.levelTwo.levelThree')).to.throw - }) - - it('does not throw for allowed non-existent path', function () { - const store = new ReactScopeValueStore() - store.allowNonExistentPath('wombat') - store.set('test', { levelOne: { levelTwo: { levelThree: 'one' } } }) - store.set('test', { levelOne: { different: 'wombat' } }) - expect(() => store.get('test')).not.to.throw - expect(store.get('wombat')).to.equal(undefined) - }) - - it('does not throw for deep allowed non-existent path', function () { - const store = new ReactScopeValueStore() - store.allowNonExistentPath('wombat', true) - expect(() => store.get('wombat')).not.to.throw - expect(() => store.get('wombat.nested')).not.to.throw - expect(() => store.get('wombat.really.very.nested')).not.to.throw - }) - - it('throws for nested value in non-deep allowed non-existent path', function () { - const store = new ReactScopeValueStore() - store.allowNonExistentPath('wombat', false) - expect(() => store.get('wombat.nested')).to.throw - }) - - it('throws for ancestor of allowed non-existent path', function () { - const store = new ReactScopeValueStore() - store.allowNonExistentPath('wombat.nested', true) - expect(() => store.get('wombat.really.very.nested')).not.to.throw - expect(() => store.get('wombat')).to.throw - }) - - it('updates ancestors', async function () { - const store = new ReactScopeValueStore() - const testValue = { - prop1: { - subProp: 'wombat', - }, - prop2: { - subProp: 'wombat', - }, - } - store.set('test', testValue) - const rootWatcher = sinon.stub() - const prop1Watcher = sinon.stub() - const subPropWatcher = sinon.stub() - const prop2Watcher = sinon.stub() - await waitForWatchers(() => { - store.watch('test', rootWatcher) - store.watch('test.prop1', prop1Watcher) - store.watch('test.prop1.subProp', subPropWatcher) - store.watch('test.prop2', prop2Watcher) - }) - rootWatcher.reset() - prop1Watcher.reset() - subPropWatcher.reset() - prop2Watcher.reset() - await waitForWatchers(() => { - store.set('test.prop1.subProp', 'picard') - }) - expect(store.get('test')).to.deep.equal({ - prop1: { - subProp: 'picard', - }, - prop2: { - subProp: 'wombat', - }, - }) - expect(store.get('test.prop2')).to.equal(testValue.prop2) - expect(rootWatcher).to.have.been.called - expect(prop1Watcher).to.have.been.called - expect(subPropWatcher).to.have.been.called - expect(prop2Watcher).not.to.have.been.called - }) - - describe('persistence', function () { - beforeEach(function () { - customLocalStorage.clear() - }) - - it('persists string to local storage', function () { - const store = new ReactScopeValueStore() - store.persisted('test-path', 'fallback value', 'test-storage-key') - expect(store.get('test-path')).to.equal('fallback value') - store.set('test-path', 'new value') - expect(customLocalStorage.getItem('test-storage-key')).to.equal( - 'new value' - ) - }) - - it("doesn't persist string to local storage until set() is called", function () { - const store = new ReactScopeValueStore() - store.persisted('test-path', 'fallback value', 'test-storage-key') - expect(customLocalStorage.getItem('test-storage-key')).to.equal(null) - }) - - it('converts persisted value', function () { - const store = new ReactScopeValueStore() - store.persisted('test-path', false, 'test-storage-key', { - toPersisted: value => (value ? 'on' : 'off'), - fromPersisted: persistedValue => persistedValue === 'on', - }) - store.set('test-path', true) - expect(customLocalStorage.getItem('test-storage-key')).to.equal('on') - }) - }) -}) diff --git a/services/web/test/frontend/helpers/editor-providers.jsx b/services/web/test/frontend/helpers/editor-providers.jsx index cfa019cf1f..9415d01919 100644 --- a/services/web/test/frontend/helpers/editor-providers.jsx +++ b/services/web/test/frontend/helpers/editor-providers.jsx @@ -3,7 +3,7 @@ import sinon from 'sinon' import { get } from 'lodash' import { SplitTestProvider } from '@/shared/context/split-test-context' -import { IdeAngularProvider } from '@/shared/context/ide-angular-provider' +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' @@ -112,7 +112,7 @@ export function EditorProviders({ return ( - + @@ -132,7 +132,7 @@ export function EditorProviders({ - + ) } diff --git a/services/web/types/angular/scope.ts b/services/web/types/angular/scope.ts deleted file mode 100644 index 48219a026a..0000000000 --- a/services/web/types/angular/scope.ts +++ /dev/null @@ -1 +0,0 @@ -export type Scope = Record diff --git a/services/web/types/ide/scope-event-emitter.ts b/services/web/types/ide/scope-event-emitter.ts deleted file mode 100644 index 7dbfae21a0..0000000000 --- a/services/web/types/ide/scope-event-emitter.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { IdeEvents } from '@/features/ide-react/create-ide-event-emitter' - -export type ScopeEventName = keyof IdeEvents - -export interface ScopeEventEmitter { - emit: ( - eventName: ScopeEventName, - broadcast: boolean, - ...detail: unknown[] - ) => void - on: ( - eventName: ScopeEventName, - listener: (...args: unknown[]) => void - ) => () => void -} diff --git a/services/web/types/ide/scope-value-store.ts b/services/web/types/ide/scope-value-store.ts deleted file mode 100644 index e24591222c..0000000000 --- a/services/web/types/ide/scope-value-store.ts +++ /dev/null @@ -1,11 +0,0 @@ -export interface ScopeValueStore { - // We can't make this generic because get() can always return undefined if - // there is no entry for the path - get: (path: string) => any - set: (path: string, value: unknown) => void - watch: ( - path: string, - callback: (newValue: T) => void, - deep: boolean - ) => () => void -}