Przeglądaj źródła

Merge pull request #15412 from overleaf/revert-15280-td-scope-store-and-emitter

Revert "IDE scope store and emitter"

GitOrigin-RevId: bb764c66830e5e30806609b2834f918cf7784dd7
Jakob Ackermann 3 lat temu
rodzic
commit
74c6085c55
23 zmienionych plików z 38 dodań i 1001 usunięć
  1. 0 14
      services/web/frontend/js/features/ide-react/connection/join-project-payload.ts
  2. 0 1
      services/web/frontend/js/features/ide-react/connection/types/socket.ts
  3. 0 135
      services/web/frontend/js/features/ide-react/context/ide-react-context.tsx
  4. 1 12
      services/web/frontend/js/features/ide-react/context/react-context-root.tsx
  5. 0 23
      services/web/frontend/js/features/ide-react/create-ide-event-emitter.ts
  6. 0 22
      services/web/frontend/js/features/ide-react/scope-event-emitter/angular-scope-event-emitter.ts
  7. 0 26
      services/web/frontend/js/features/ide-react/scope-event-emitter/react-scope-event-emitter.ts
  8. 0 28
      services/web/frontend/js/features/ide-react/scope-value-store/angular-scope-value-store.ts
  9. 0 330
      services/web/frontend/js/features/ide-react/scope-value-store/react-scope-value-store.ts
  10. 0 4
      services/web/frontend/js/features/ide-react/types/cursor-position.ts
  11. 0 5
      services/web/frontend/js/features/ide-react/types/goto-line-options.ts
  12. 0 1
      services/web/frontend/js/features/ide-react/types/permissions-level.ts
  13. 0 16
      services/web/frontend/js/shared/context/ide-angular-provider.tsx
  14. 8 29
      services/web/frontend/js/shared/context/ide-context.tsx
  15. 3 4
      services/web/frontend/js/shared/context/root-context.jsx
  16. 8 5
      services/web/frontend/js/shared/hooks/use-scope-event-emitter.ts
  17. 4 5
      services/web/frontend/js/shared/hooks/use-scope-event-listener.ts
  18. 11 12
      services/web/frontend/js/shared/hooks/use-scope-value.ts
  19. 0 299
      services/web/test/frontend/features/ide-react/unit/react-scope-value-store.test.ts
  20. 3 3
      services/web/test/frontend/helpers/editor-providers.jsx
  21. 0 1
      services/web/types/angular/scope.ts
  22. 0 15
      services/web/types/ide/scope-event-emitter.ts
  23. 0 11
      services/web/types/ide/scope-value-store.ts

+ 0 - 14
services/web/frontend/js/features/ide-react/connection/join-project-payload.ts

@@ -1,14 +0,0 @@
-import { Project } from '../../../../../types/project'
-import { PermissionsLevel } from '../types/permissions-level'
-
-export type JoinProjectPayloadProject = Pick<
-  Project,
-  Exclude<keyof Project, ['rootDocId', 'publicAccessLevel']>
-> & { rootDoc_id?: string; publicAccesLevel?: string }
-
-export type JoinProjectPayload = {
-  permissionsLevel: PermissionsLevel
-  project: JoinProjectPayloadProject
-  protocolVersion: number
-  publicId: string
-}

+ 0 - 1
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,

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

@@ -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<IdeReactContextValue | null>(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 (
-    <IdeReactContext.Provider value={value}>
-      <IdeProvider
-        ide={ide}
-        scopeStore={scopeStore}
-        scopeEventEmitter={scopeEventEmitter}
-      >
-        {children}
-      </IdeProvider>
-    </IdeReactContext.Provider>
-  )
-}
-
-export function useIdeReactContext(): IdeReactContextValue {
-  const context = useContext(IdeReactContext)
-
-  if (!context) {
-    throw new Error(
-      'useIdeReactContext is only available inside IdeReactProvider'
-    )
-  }
-
-  return context
-}

+ 1 - 12
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 (
-    <ConnectionProvider>
-      <IdeReactProvider>
-        <UserProvider>
-          <ProjectProvider>{children}</ProjectProvider>
-        </UserProvider>
-      </IdeReactProvider>
-    </ConnectionProvider>
-  )
+  return <ConnectionProvider>{children}</ConnectionProvider>
 }

+ 0 - 23
services/web/frontend/js/features/ide-react/create-ide-event-emitter.ts

@@ -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<IdeEvents>
-
-export function createIdeEventEmitter(): IdeEventEmitter {
-  return new Emitter<IdeEvents>()
-}

+ 0 - 22
services/web/frontend/js/features/ide-react/scope-event-emitter/angular-scope-event-emitter.ts

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

+ 0 - 26
services/web/frontend/js/features/ide-react/scope-event-emitter/react-scope-event-emitter.ts

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

+ 0 - 28
services/web/frontend/js/features/ide-react/scope-value-store/angular-scope-value-store.ts

@@ -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<T>(
-    path: string,
-    callback: (newValue: T) => void,
-    deep: boolean
-  ): () => void {
-    return this.$scope.$watch(
-      path,
-      (newValue: T) => callback(deep ? _.cloneDeep(newValue) : newValue),
-      deep
-    )
-  }
-}

+ 0 - 330
services/web/frontend/js/features/ide-react/scope-value-store/react-scope-value-store.ts

@@ -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<T> = {
-  removed: boolean
-  callback: (value: T) => void
-}
-
-// A value that has been set
-type ScopeValueStoreValue<T = any> = {
-  value?: T
-  watchers: Watcher<T>[]
-}
-
-type WatcherUpdate<T = any> = {
-  path: string
-  value: T
-  watchers: Watcher<T>[]
-}
-
-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<string, ScopeValueStoreValue>()
-  private readonly persisters: Map<string, Persister> = 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<T>(path: string): ScopeValueStoreValue<T> | 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<string, any>) {
-    const newValue: Record<string, any> = { ...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<T>(
-    path: string,
-    value: T,
-    watchers: Watcher<T>[]
-  ) {
-    // 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<T>(path: string) {
-    return this.getItem<T>(path).value
-  }
-
-  private setValue<T>(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<T>(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<T>(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<T>(path: string, callback: Watcher<T>['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<T>(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<Value, PersistedValue>(
-    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
-  }
-}

+ 0 - 4
services/web/frontend/js/features/ide-react/types/cursor-position.ts

@@ -1,4 +0,0 @@
-export type CursorPosition = {
-  row: number
-  column: number
-}

+ 0 - 5
services/web/frontend/js/features/ide-react/types/goto-line-options.ts

@@ -1,5 +0,0 @@
-export interface GotoLineOptions {
-  gotoLine: number
-  gotoColumn?: number
-  syncToPdf?: boolean
-}

+ 0 - 1
services/web/frontend/js/features/ide-react/types/permissions-level.ts

@@ -1 +0,0 @@
-export type PermissionsLevel = 'owner' | 'readAndWrite' | 'readOnly'

+ 0 - 16
services/web/frontend/js/shared/context/ide-angular-provider.tsx

@@ -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 (
-    <IdeProvider
-      ide={ide}
-      scopeStore={new AngularScopeValueStore(ide.$scope)}
-      scopeEventEmitter={new AngularScopeEventEmitter(ide.$scope)}
-    >
-      {children}
-    </IdeProvider>
-  )
-}

+ 8 - 29
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<string, any>
 }
 
-type IdeContextValue = Ide & {
-  isReactIde: boolean
-  scopeStore: ScopeValueStore
-  scopeEventEmitter: ScopeEventEmitter
-}
-
-const IdeContext = createContext<IdeContextValue | undefined>(undefined)
-const isReactIde: boolean = getMeta('ol-idePageReact')
+const IdeContext = createContext<Ide | null>(null)
 
-export const IdeProvider: FC<{
-  ide: Ide
-  scopeStore: ScopeValueStore
-  scopeEventEmitter: ScopeEventEmitter
-}> = ({ ide, scopeStore, scopeEventEmitter, children }) => {
-  const value = useMemo<IdeContextValue>(() => {
-    return {
-      ...ide,
-      isReactIde,
-      scopeStore,
-      scopeEventEmitter,
-    }
-  }, [ide, scopeStore, scopeEventEmitter])
+export const IdeProvider: FC<{ ide: Ide }> = ({ ide, children }) => {
+  const [value] = useState(() => ide || getMockIde())
 
   return <IdeContext.Provider value={value}>{children}</IdeContext.Provider>
 }
 
-export function useIdeContext(): IdeContextValue {
+export function useIdeContext(): Ide {
   const context = useContext(IdeContext)
 
   if (!context) {

+ 3 - 4
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 (
     <SplitTestProvider>
-      <IdeAngularProvider ide={ide || getMockIde()}>
+      <IdeProvider ide={ide}>
         <UserProvider>
           <ProjectProvider>
             <FileTreeDataProvider>
@@ -38,7 +37,7 @@ export function ContextRoot({ children, ide }) {
             </FileTreeDataProvider>
           </ProjectProvider>
         </UserProvider>
-      </IdeAngularProvider>
+      </IdeProvider>
     </SplitTestProvider>
   )
 }

+ 8 - 5
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]
   )
 }

+ 4 - 5
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])
 }

+ 11 - 12
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<T = any>(
   path: string, // dot '.' path of a property in the Angular scope
   deep = false
 ): [T, Dispatch<SetStateAction<T>>] {
-  const { scopeStore } = useIdeContext()
+  const { $scope } = useIdeContext()
 
-  const [value, setValue] = useState<T>(() => scopeStore.get(path))
+  const [value, setValue] = useState<T>(() => _.get($scope, path))
 
   useEffect(() => {
-    return scopeStore.watch<T>(
+    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<T>) => {
       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]

+ 0 - 299
services/web/test/frontend/features/ide-react/unit/react-scope-value-store.test.ts

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

+ 3 - 3
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 (
     <SplitTestProvider>
-      <IdeAngularProvider ide={window._ide}>
+      <IdeProvider ide={window._ide}>
         <UserProvider>
           <ProjectProvider>
             <FileTreeDataProvider>
@@ -132,7 +132,7 @@ export function EditorProviders({
             </FileTreeDataProvider>
           </ProjectProvider>
         </UserProvider>
-      </IdeAngularProvider>
+      </IdeProvider>
     </SplitTestProvider>
   )
 }

+ 0 - 1
services/web/types/angular/scope.ts

@@ -1 +0,0 @@
-export type Scope = Record<string, any>

+ 0 - 15
services/web/types/ide/scope-event-emitter.ts

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

+ 0 - 11
services/web/types/ide/scope-value-store.ts

@@ -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: <T>(
-    path: string,
-    callback: (newValue: T) => void,
-    deep: boolean
-  ) => () => void
-}