Răsfoiți Sursa

Merge pull request #15415 from overleaf/td-scope-store-and-emitter-fixed

IDE scope store and emitter with fixed PDF URLs

GitOrigin-RevId: 9d33bad8a006bb55714878332f78932538dd8921
Tim Down 2 ani în urmă
părinte
comite
537673cdf6
23 a modificat fișierele cu 1009 adăugiri și 38 ștergeri
  1. 14 0
      services/web/frontend/js/features/ide-react/connection/join-project-payload.ts
  2. 1 0
      services/web/frontend/js/features/ide-react/connection/types/socket.ts
  3. 135 0
      services/web/frontend/js/features/ide-react/context/ide-react-context.tsx
  4. 12 1
      services/web/frontend/js/features/ide-react/context/react-context-root.tsx
  5. 23 0
      services/web/frontend/js/features/ide-react/create-ide-event-emitter.ts
  6. 22 0
      services/web/frontend/js/features/ide-react/scope-event-emitter/angular-scope-event-emitter.ts
  7. 26 0
      services/web/frontend/js/features/ide-react/scope-event-emitter/react-scope-event-emitter.ts
  8. 28 0
      services/web/frontend/js/features/ide-react/scope-value-store/angular-scope-value-store.ts
  9. 330 0
      services/web/frontend/js/features/ide-react/scope-value-store/react-scope-value-store.ts
  10. 4 0
      services/web/frontend/js/features/ide-react/types/cursor-position.ts
  11. 5 0
      services/web/frontend/js/features/ide-react/types/goto-line-options.ts
  12. 1 0
      services/web/frontend/js/features/ide-react/types/permissions-level.ts
  13. 25 0
      services/web/frontend/js/shared/context/ide-angular-provider.tsx
  14. 29 8
      services/web/frontend/js/shared/context/ide-context.tsx
  15. 3 3
      services/web/frontend/js/shared/context/root-context.jsx
  16. 5 8
      services/web/frontend/js/shared/hooks/use-scope-event-emitter.ts
  17. 5 4
      services/web/frontend/js/shared/hooks/use-scope-event-listener.ts
  18. 12 11
      services/web/frontend/js/shared/hooks/use-scope-value.ts
  19. 299 0
      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. 1 0
      services/web/types/angular/scope.ts
  22. 15 0
      services/web/types/ide/scope-event-emitter.ts
  23. 11 0
      services/web/types/ide/scope-value-store.ts

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

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

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

@@ -1,6 +1,7 @@
 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,

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

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

+ 12 - 1
services/web/frontend/js/features/ide-react/context/react-context-root.tsx

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

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

@@ -0,0 +1,23 @@
+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>()
+}

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

@@ -0,0 +1,22 @@
+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)
+  }
+}

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

@@ -0,0 +1,26 @@
+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)
+    }
+  }
+}

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

@@ -0,0 +1,28 @@
+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
+    )
+  }
+}

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

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

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

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

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

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

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

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

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

@@ -0,0 +1,25 @@
+import { FC, useState } 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'
+import { getMockIde } from '@/shared/context/mock/mock-ide'
+
+export const IdeAngularProvider: FC<{ ide?: Ide }> = ({ ide, children }) => {
+  const [ideValue] = useState(() => ide || getMockIde())
+  const [scopeStore] = useState(
+    () => new AngularScopeValueStore(ideValue.$scope)
+  )
+  const [scopeEventEmitter] = useState(
+    () => new AngularScopeEventEmitter(ideValue.$scope)
+  )
+
+  return (
+    <IdeProvider
+      ide={ideValue}
+      scopeStore={scopeStore}
+      scopeEventEmitter={scopeEventEmitter}
+    >
+      {children}
+    </IdeProvider>
+  )
+}

+ 29 - 8
services/web/frontend/js/shared/context/ide-context.tsx

@@ -1,20 +1,41 @@
-import { createContext, FC, useContext, useState } from 'react'
-import { getMockIde } from './mock/mock-ide'
+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'
 
-type Ide = {
+export type Ide = {
   [key: string]: any // TODO: define the rest of the `ide` and `$scope` properties
-  $scope: Record<string, any>
+  $scope: Scope
 }
 
-const IdeContext = createContext<Ide | null>(null)
+type IdeContextValue = Ide & {
+  isReactIde: boolean
+  scopeStore: ScopeValueStore
+  scopeEventEmitter: ScopeEventEmitter
+}
+
+const IdeContext = createContext<IdeContextValue | undefined>(undefined)
+const isReactIde: boolean = getMeta('ol-idePageReact')
 
-export const IdeProvider: FC<{ ide: Ide }> = ({ ide, children }) => {
-  const [value] = useState(() => ide || getMockIde())
+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])
 
   return <IdeContext.Provider value={value}>{children}</IdeContext.Provider>
 }
 
-export function useIdeContext(): Ide {
+export function useIdeContext(): IdeContextValue {
   const context = useContext(IdeContext)
 
   if (!context) {

+ 3 - 3
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 { IdeProvider } from './ide-context'
+import { IdeAngularProvider } from './ide-angular-provider'
 import { EditorProvider } from './editor-context'
 import { LocalCompileProvider } from './local-compile-context'
 import { DetachCompileProvider } from './detach-compile-context'
@@ -17,7 +17,7 @@ import { ProjectSettingsProvider } from '@/features/editor-left-menu/context/pro
 export function ContextRoot({ children, ide }) {
   return (
     <SplitTestProvider>
-      <IdeProvider ide={ide}>
+      <IdeAngularProvider ide={ide}>
         <UserProvider>
           <ProjectProvider>
             <FileTreeDataProvider>
@@ -37,7 +37,7 @@ export function ContextRoot({ children, ide }) {
             </FileTreeDataProvider>
           </ProjectProvider>
         </UserProvider>
-      </IdeProvider>
+      </IdeAngularProvider>
     </SplitTestProvider>
   )
 }

+ 5 - 8
services/web/frontend/js/shared/hooks/use-scope-event-emitter.ts

@@ -1,20 +1,17 @@
 import { useCallback } from 'react'
 import { useIdeContext } from '../context/ide-context'
+import { ScopeEventName } from '../../../../types/ide/scope-event-emitter'
 
 export default function useScopeEventEmitter(
-  eventName: string,
+  eventName: ScopeEventName,
   broadcast = true
 ) {
-  const { $scope } = useIdeContext()
+  const { scopeEventEmitter } = useIdeContext()
 
   return useCallback(
     (...detail: unknown[]) => {
-      if (broadcast) {
-        $scope.$broadcast(eventName, ...detail)
-      } else {
-        $scope.$emit(eventName, ...detail)
-      }
+      scopeEventEmitter.emit(eventName, broadcast, ...detail)
     },
-    [$scope, eventName, broadcast]
+    [scopeEventEmitter, eventName, broadcast]
   )
 }

+ 5 - 4
services/web/frontend/js/shared/hooks/use-scope-event-listener.ts

@@ -1,13 +1,14 @@
 import { useEffect } from 'react'
 import { useIdeContext } from '../context/ide-context'
+import { ScopeEventName } from '../../../../types/ide/scope-event-emitter'
 
 export default function useScopeEventListener(
-  eventName: string,
+  eventName: ScopeEventName,
   listener: (...args: unknown[]) => void
 ) {
-  const { $scope } = useIdeContext()
+  const { scopeEventEmitter } = useIdeContext()
 
   useEffect(() => {
-    return $scope.$on(eventName, listener)
-  }, [$scope, eventName, listener])
+    return scopeEventEmitter.on(eventName, listener)
+  }, [scopeEventEmitter, eventName, listener])
 }

+ 12 - 11
services/web/frontend/js/shared/hooks/use-scope-value.ts

@@ -12,38 +12,39 @@ 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 { $scope } = useIdeContext()
+  const { scopeStore } = useIdeContext()
 
-  const [value, setValue] = useState<T>(() => _.get($scope, path))
+  const [value, setValue] = useState<T>(() => scopeStore.get(path))
 
   useEffect(() => {
-    return $scope.$watch(
+    return scopeStore.watch<T>(
       path,
       (newValue: T) => {
-        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
-        })
+        // NOTE: this is deliberately wrapped in a function,
+        // to avoid calling setValue directly with a value that's a function
+        setValue(() => newValue)
       },
       deep
     )
-  }, [path, $scope, deep])
+  }, [path, scopeStore, deep])
 
   const scopeSetter = useCallback(
     (newValue: SetStateAction<T>) => {
       setValue(val => {
         const actualNewValue = _.isFunction(newValue) ? newValue(val) : newValue
-        $scope.$applyAsync(() => _.set($scope, path, actualNewValue))
+        scopeStore.set(path, actualNewValue)
         return actualNewValue
       })
     },
-    [path, $scope]
+    [path, scopeStore]
   )
 
   return [value, scopeSetter]

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

@@ -0,0 +1,299 @@
+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 { IdeProvider } from '@/shared/context/ide-context'
+import { IdeAngularProvider } from '@/shared/context/ide-angular-provider'
 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>
-      <IdeProvider ide={window._ide}>
+      <IdeAngularProvider ide={window._ide}>
         <UserProvider>
           <ProjectProvider>
             <FileTreeDataProvider>
@@ -132,7 +132,7 @@ export function EditorProviders({
             </FileTreeDataProvider>
           </ProjectProvider>
         </UserProvider>
-      </IdeProvider>
+      </IdeAngularProvider>
     </SplitTestProvider>
   )
 }

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

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

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

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

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

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