Skip to content
This repository was archived by the owner on Nov 6, 2018. It is now read-only.

Commit da33526

Browse files
committed
feat(extension): optimistically apply local config updates
1 parent 6d840c8 commit da33526

File tree

2 files changed

+137
-5
lines changed

2 files changed

+137
-5
lines changed

src/extension/features/configuration.test.ts

+68-3
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
} from '../../protocol'
1010
import { Configuration, Observable } from '../api'
1111
import { observableValue } from '../util'
12-
import { createExtConfiguration } from './configuration'
12+
import { createExtConfiguration, setValueAtKeyPath } from './configuration'
1313

1414
interface Settings {
1515
[key: string]: string
@@ -41,16 +41,81 @@ describe('ExtConfiguration', () => {
4141
})
4242

4343
describe('update', () => {
44-
it('sends to the client', async () => {
44+
it('sends to the client and immediately reflects locally', async () => {
4545
const { extConfiguration, mockConnection } = create()
4646
mockConnection.mockResults.set(ConfigurationUpdateRequest.type.method, void 0)
47-
await extConfiguration.update('a', 'b')
47+
const updated = extConfiguration.update('a', 'b')
48+
const want = { a: 'b' } as Settings
49+
assert.deepStrictEqual(observableValue(extConfiguration), want)
50+
await updated
51+
assert.deepStrictEqual(observableValue(extConfiguration), want)
4852
assert.deepStrictEqual(mockConnection.sentMessages, [
4953
{
5054
method: ConfigurationUpdateRequest.type.method,
5155
params: { path: ['a'], value: 'b' } as ConfigurationUpdateParams,
5256
},
5357
])
5458
})
59+
60+
it('handles interleaved update calls and didChangeConfiguration notifications', async () => {
61+
const { extConfiguration, mockConnection } = create()
62+
mockConnection.mockResults.set(ConfigurationUpdateRequest.type.method, void 0)
63+
const updated = extConfiguration.update('a', 'b')
64+
assert.deepStrictEqual(observableValue(extConfiguration), { a: 'b' } as Settings)
65+
mockConnection.recvNotification(DidChangeConfigurationNotification.type.method, {
66+
configurationCascade: { merged: { c: 'd' } as Settings },
67+
} as DidChangeConfigurationParams)
68+
assert.deepStrictEqual(observableValue(extConfiguration), { c: 'd' } as Settings)
69+
await updated
70+
assert.deepStrictEqual(observableValue(extConfiguration), { c: 'd' } as Settings)
71+
})
72+
})
73+
})
74+
75+
describe('setValueAtKeyPath', () => {
76+
it('overwrites the top level', () => assert.deepStrictEqual(setValueAtKeyPath({ a: 1 }, [], { b: 2 }), { b: 2 }))
77+
it('overwrites an existing property', () => assert.deepStrictEqual(setValueAtKeyPath({ a: 1 }, ['a'], 2), { a: 2 }))
78+
it('sets a new property', () => assert.deepStrictEqual(setValueAtKeyPath({ a: 1 }, ['b'], 2), { a: 1, b: 2 }))
79+
it('sets a property overwriting an array', () => assert.deepStrictEqual(setValueAtKeyPath([1], ['a'], 2), { a: 2 }))
80+
it('sets a property overwriting a primitive', () =>
81+
assert.deepStrictEqual(setValueAtKeyPath(1 as any, ['a'], 2), { a: 2 }))
82+
it('overwrites an existing nested property', () =>
83+
assert.deepStrictEqual(setValueAtKeyPath({ a: { b: 1 } }, ['a', 'b'], 2), { a: { b: 2 } }))
84+
it('deletes a property', () =>
85+
assert.deepStrictEqual(setValueAtKeyPath({ a: 1, b: 2 }, ['a'], undefined), { b: 2 }))
86+
it('sets a new nested property', () =>
87+
assert.deepStrictEqual(setValueAtKeyPath({ a: { b: 1 } }, ['a', 'c'], 2), { a: { b: 1, c: 2 } }))
88+
it('sets a new deeply nested property', () =>
89+
assert.deepStrictEqual(setValueAtKeyPath({ a: { b: { c: 1 } } }, ['a', 'b', 'd'], 2), {
90+
a: { b: { c: 1, d: 2 } },
91+
}))
92+
it('overwrites an object', () => assert.deepStrictEqual(setValueAtKeyPath({ a: { b: 1 } }, ['a'], 2), { a: 2 }))
93+
it('sets a value that requires a new object', () =>
94+
assert.deepStrictEqual(setValueAtKeyPath({}, ['a', 'b'], 1), { a: { b: 1 } }))
95+
96+
it('overwrites an existing index', () => assert.deepStrictEqual(setValueAtKeyPath([1], [0], 2), [2]))
97+
it('inserts a new index', () => assert.deepStrictEqual(setValueAtKeyPath([1], [1], 2), [1, 2]))
98+
it('inserts a new index at end', () => assert.deepStrictEqual(setValueAtKeyPath([1, 2], [-1], 3), [1, 2, 3]))
99+
it('inserts an index overwriting an object', () => assert.deepStrictEqual(setValueAtKeyPath({ a: 1 }, [0], 2), [2]))
100+
it('inserts an index overwriting a primitive', () =>
101+
assert.deepStrictEqual(setValueAtKeyPath(1 as any, [0], 2), [2]))
102+
it('overwrites an existing nested index', () =>
103+
assert.deepStrictEqual(setValueAtKeyPath([1, [2]], [1, 0], 3), [1, [3]]))
104+
it('deletes an index', () => assert.deepStrictEqual(setValueAtKeyPath([1, 2, 3], [1], undefined), [1, 3]))
105+
it('sets a new nested index', () =>
106+
assert.deepStrictEqual(setValueAtKeyPath([1, [1, 2, [1, 2, 3, 4]]], [1, 2, 3], 5), [1, [1, 2, [1, 2, 3, 5]]]))
107+
it('inserts a new nested index at end', () =>
108+
assert.deepStrictEqual(setValueAtKeyPath([1, [2]], [1, -1], 3), [1, [2, 3]]))
109+
it('overwrites an array', () => assert.deepStrictEqual(setValueAtKeyPath([1, [2]], [1], 3), [1, 3]))
110+
it('sets a value that requires a new array', () => assert.deepStrictEqual(setValueAtKeyPath([], [0, 0], 1), [[1]]))
111+
112+
it('sets a nested property (and does not modify input)', () => {
113+
const input = { a: [{}, { b: [1, 2] }] }
114+
const origInput = JSON.parse(JSON.stringify(input))
115+
assert.deepStrictEqual(setValueAtKeyPath(input, ['a', 1, 'b', -1], { c: 3 }), {
116+
a: [{}, { b: [1, 2, { c: 3 }] }],
117+
})
118+
assert.deepStrictEqual(input, origInput)
55119
})
120+
it('throws on invalid key type', () => assert.throws(() => setValueAtKeyPath({}, [true as any], {})))
56121
})

src/extension/features/configuration.ts

+69-2
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
ConfigurationUpdateParams,
66
ConfigurationUpdateRequest,
77
DidChangeConfigurationNotification,
8+
KeyPath,
89
} from '../../protocol'
910
import { isEqual } from '../../util'
1011
import { Configuration, CXP, Observable } from '../api'
@@ -27,16 +28,82 @@ class ExtConfiguration<C> extends BehaviorSubject<C> implements Configuration<C>
2728
}
2829

2930
public update<K extends keyof C>(key: K, value: C[K] | undefined): Promise<void> {
30-
// TODO!(sqs): update config locally here
31+
const path: KeyPath = [key as string | number]
32+
33+
// Optimistically apply configuration update locally. If this diverges from the server's state, a
34+
// subsequent didChangeConfiguration notification will inform us.
35+
const cur = this.value
36+
this.next(setValueAtKeyPath(cur, path, value))
37+
3138
return this.ext.rawConnection.sendRequest(ConfigurationUpdateRequest.type, {
32-
path: [key],
39+
path,
3340
value,
3441
} as ConfigurationUpdateParams)
3542
}
3643

3744
public readonly [Symbol.observable] = () => this
3845
}
3946

47+
/**
48+
* Returns the source object with the given value inserted in the location specified by the key path. The source
49+
* object is not modified. The key path indexes into the object successively for each element in the key path.
50+
*
51+
* If the value is `undefined`, the value at the key path is removed.
52+
*
53+
* This must behave identically to {@link module:jsonc-parser.setProperty}.
54+
*/
55+
export function setValueAtKeyPath(source: any, path: KeyPath, value: any): any {
56+
if (path.length === 0) {
57+
// Overwrite entire value.
58+
return value
59+
}
60+
61+
const root = [source]
62+
let prev: any = root // maintain an lvalue that we can assign to
63+
for (const [i, key] of path.entries()) {
64+
const last = i === path.length - 1
65+
const prevKey = i === 0 ? 0 : path[i - 1]
66+
if (typeof key === 'string') {
67+
if (last) {
68+
if (value === undefined) {
69+
prev[prevKey] = { ...prev[prevKey] }
70+
delete prev[prevKey][key]
71+
} else if (
72+
prev[prevKey] !== null &&
73+
typeof prev[prevKey] === 'object' &&
74+
!Array.isArray(prev[prevKey])
75+
) {
76+
prev[prevKey] = { ...prev[prevKey], [key]: value }
77+
} else {
78+
prev[prevKey] = { [key]: value }
79+
}
80+
} else {
81+
prev[prevKey] =
82+
prev[prevKey] !== null && typeof prev[prevKey] === 'object' && !Array.isArray(prev[prevKey])
83+
? { ...prev[prevKey] }
84+
: {}
85+
}
86+
} else if (typeof key === 'number') {
87+
if (last) {
88+
const index = key === -1 ? prev[prevKey].length : key
89+
const head = Array.isArray(prev[prevKey]) ? prev[prevKey].slice(0, index) : []
90+
const tail = Array.isArray(prev[prevKey]) ? prev[prevKey].slice(index + 1) : []
91+
if (value === undefined) {
92+
prev[prevKey] = [...head, ...tail]
93+
} else {
94+
prev[prevKey] = [...head, value, ...tail]
95+
}
96+
} else {
97+
prev[prevKey] = Array.isArray(prev[prevKey]) ? [...prev[prevKey]] : []
98+
}
99+
} else {
100+
throw new Error(`invalid key in key path: ${key} (full key path: ${JSON.stringify(path)}`)
101+
}
102+
prev = prev[prevKey]
103+
}
104+
return root[0]
105+
}
106+
40107
/**
41108
* Creates the CXP extension API's {@link CXP#configuration} value.
42109
*

0 commit comments

Comments
 (0)