| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333 |
- 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 = new Map<string, 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.values()]
- this.watcherUpdates = new Map()
- 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.set(path, 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 {
- debugConsole.log('setValue', path, value)
- 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) {
- // add this watcher to any existing watchers scheduled for this path
- const { watchers } = this.watcherUpdates.get(path) ?? { watchers: [] }
- this.scheduleWatcherUpdate<T>(path, item.value, [...watchers, 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
- }
- }
|