From 537673cdf6667cb1f41da0840a0e8a2b8b4d93c6 Mon Sep 17 00:00:00 2001 From: Tim Down <158919+timdown@users.noreply.github.com> Date: Tue, 24 Oct 2023 15:57:17 +0100 Subject: [PATCH] Merge pull request #15415 from overleaf/td-scope-store-and-emitter-fixed IDE scope store and emitter with fixed PDF URLs GitOrigin-RevId: 9d33bad8a006bb55714878332f78932538dd8921 --- .../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 | 25 ++ .../js/shared/context/ide-context.tsx | 37 +- .../js/shared/context/root-context.jsx | 6 +- .../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, 1009 insertions(+), 38 deletions(-) create mode 100644 services/web/frontend/js/features/ide-react/connection/join-project-payload.ts create mode 100644 services/web/frontend/js/features/ide-react/context/ide-react-context.tsx create mode 100644 services/web/frontend/js/features/ide-react/create-ide-event-emitter.ts create mode 100644 services/web/frontend/js/features/ide-react/scope-event-emitter/angular-scope-event-emitter.ts create mode 100644 services/web/frontend/js/features/ide-react/scope-event-emitter/react-scope-event-emitter.ts create mode 100644 services/web/frontend/js/features/ide-react/scope-value-store/angular-scope-value-store.ts create mode 100644 services/web/frontend/js/features/ide-react/scope-value-store/react-scope-value-store.ts create mode 100644 services/web/frontend/js/features/ide-react/types/cursor-position.ts create mode 100644 services/web/frontend/js/features/ide-react/types/goto-line-options.ts create mode 100644 services/web/frontend/js/features/ide-react/types/permissions-level.ts create mode 100644 services/web/frontend/js/shared/context/ide-angular-provider.tsx create mode 100644 services/web/test/frontend/features/ide-react/unit/react-scope-value-store.test.ts create mode 100644 services/web/types/angular/scope.ts create mode 100644 services/web/types/ide/scope-event-emitter.ts create 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 new file mode 100644 index 0000000000..a06f80f20b --- /dev/null +++ b/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 +> & { 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 c79cde2fa1..3f0cc97840 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,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, 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 new file mode 100644 index 0000000000..247019c302 --- /dev/null +++ b/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(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 e580f5706b..acd64d9cd3 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,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 {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 new file mode 100644 index 0000000000..a8c8ef7131 --- /dev/null +++ b/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 + +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 new file mode 100644 index 0000000000..75173949bf --- /dev/null +++ b/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) + } +} 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 new file mode 100644 index 0000000000..96cdb2987c --- /dev/null +++ b/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) + } + } +} 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 new file mode 100644 index 0000000000..237be90e58 --- /dev/null +++ b/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( + 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 new file mode 100644 index 0000000000..e61131ab80 --- /dev/null +++ b/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 = { + 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 new file mode 100644 index 0000000000..ed7f4138e1 --- /dev/null +++ b/services/web/frontend/js/features/ide-react/types/cursor-position.ts @@ -0,0 +1,4 @@ +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 new file mode 100644 index 0000000000..2fcd1d8417 --- /dev/null +++ b/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 +} 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 new file mode 100644 index 0000000000..43a6612653 --- /dev/null +++ b/services/web/frontend/js/features/ide-react/types/permissions-level.ts @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000000..f0780f135b --- /dev/null +++ b/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 ( + + {children} + + ) +} diff --git a/services/web/frontend/js/shared/context/ide-context.tsx b/services/web/frontend/js/shared/context/ide-context.tsx index 5a79cb69e0..4ece0bf762 100644 --- a/services/web/frontend/js/shared/context/ide-context.tsx +++ b/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 + $scope: Scope } -const IdeContext = createContext(null) +type IdeContextValue = Ide & { + isReactIde: boolean + scopeStore: ScopeValueStore + scopeEventEmitter: ScopeEventEmitter +} -export const IdeProvider: FC<{ ide: Ide }> = ({ ide, children }) => { - const [value] = useState(() => ide || getMockIde()) +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]) return {children} } -export function useIdeContext(): Ide { +export function useIdeContext(): IdeContextValue { 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 457fc36336..aac1dccc0d 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 { 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 ( - + @@ -37,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 dd66577187..788fce9ab4 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,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] ) } 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 cbc2d200c1..1c3b59df12 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,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]) } 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 e3f3a50b75..38a716a1eb 100644 --- a/services/web/frontend/js/shared/hooks/use-scope-value.ts +++ b/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( path: string, // dot '.' path of a property in the Angular scope deep = false ): [T, Dispatch>] { - const { $scope } = useIdeContext() + const { scopeStore } = useIdeContext() - const [value, setValue] = useState(() => _.get($scope, path)) + const [value, setValue] = useState(() => scopeStore.get(path)) useEffect(() => { - return $scope.$watch( + return scopeStore.watch( 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) => { 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] 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 new file mode 100644 index 0000000000..4b3be365ae --- /dev/null +++ b/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') + }) + }) +}) diff --git a/services/web/test/frontend/helpers/editor-providers.jsx b/services/web/test/frontend/helpers/editor-providers.jsx index 9415d01919..cfa019cf1f 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 { 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 ( - + @@ -132,7 +132,7 @@ export function EditorProviders({ - + ) } diff --git a/services/web/types/angular/scope.ts b/services/web/types/angular/scope.ts new file mode 100644 index 0000000000..48219a026a --- /dev/null +++ b/services/web/types/angular/scope.ts @@ -0,0 +1 @@ +export type Scope = Record diff --git a/services/web/types/ide/scope-event-emitter.ts b/services/web/types/ide/scope-event-emitter.ts new file mode 100644 index 0000000000..7dbfae21a0 --- /dev/null +++ b/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 +} diff --git a/services/web/types/ide/scope-value-store.ts b/services/web/types/ide/scope-value-store.ts new file mode 100644 index 0000000000..e24591222c --- /dev/null +++ b/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: ( + path: string, + callback: (newValue: T) => void, + deep: boolean + ) => () => void +}