Merge f22dbb7036a4cff3140a55cc7580e91d6538abd8 into ad0d915794713a4cfb6c0fc32a3b998278301b36

This commit is contained in:
Mel Bourgeois 2025-03-18 23:26:19 +01:00 committed by GitHub
commit f9bf59fba4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 162 additions and 36 deletions

View File

@ -21,12 +21,37 @@ import * as iterator from 'lib0/iterator'
/**
* @template T
* @typedef {Extract<keyof T, string>} StringKey<T>
*
* Like keyof, but guarantees the returned type is a subset of string.
* `keyof` will include number and Symbol even if the input type requires string keys.
*/
/**
* @template T
* @typedef {readonly {
* [K in StringKey<T>]: [K, T[K]];
* }[StringKey<T>][]} EntriesOf<T>
*
* Converts an object schema into a readonly array containing valid key-value pairs.
*/
/**
* This works around some weird JSDoc+TS circular reference issues: https://github.com/microsoft/TypeScript/issues/46369
* @typedef {boolean|null|string|number|Uint8Array|JsonArray|JsonObject} Json
* @typedef {Json[]} JsonArray
* @typedef {{ [key: string]: Json }} JsonObject
* @typedef {Json|AbstractType<any>|Doc} MapValue
*/
/**
* @template {Record<string, MapValue>} T
* @extends YEvent<YMap<T>>
* Event that describes the changes on a YMap.
*/
export class YMapEvent extends YEvent {
/**
* @param {YMap<T>} ymap The YArray that changed.
* @param {YMap<T>} ymap The YMap that changed.
* @param {Transaction} transaction
* @param {Set<any>} subs The keys that changed.
*/
@ -37,21 +62,21 @@ export class YMapEvent extends YEvent {
}
/**
* @template MapType
* @template {Record<string, MapValue>} MapType
* A shared Map implementation.
*
* @extends AbstractType<YMapEvent<MapType>>
* @implements {Iterable<[string, MapType]>}
* @implements {Iterable<[StringKey<MapType>, MapType[StringKey<MapType>]]>}
*/
export class YMap extends AbstractType {
/**
*
* @param {Iterable<readonly [string, any]>=} entries - an optional iterable to initialize the YMap
* @param {EntriesOf<MapType>=} entries - an optional iterable to initialize the YMap
*/
constructor (entries) {
super()
/**
* @type {Map<string,any>?}
* @type {Map<StringKey<MapType>, MapType[StringKey<MapType>]>?}
* @private
*/
this._prelimContent = null
@ -75,7 +100,7 @@ export class YMap extends AbstractType {
*/
_integrate (y, item) {
super._integrate(y, item)
;/** @type {Map<string, any>} */ (this._prelimContent).forEach((value, key) => {
this._prelimContent?.forEach((value, key) => {
this.set(key, value)
})
this._prelimContent = null
@ -119,12 +144,12 @@ export class YMap extends AbstractType {
/**
* Transforms this Shared Type to a JSON object.
*
* @return {Object<string,any>}
* @return {Partial<MapType>}
*/
toJSON () {
this.doc ?? warnPrematureAccess()
/**
* @type {Object<string,MapType>}
* @type {any}
*/
const map = {}
this._map.forEach((item, key) => {
@ -157,7 +182,7 @@ export class YMap extends AbstractType {
/**
* Returns the values for each element in the YMap Type.
*
* @return {IterableIterator<MapType>}
* @return {IterableIterator<MapType[keyof MapType]>}
*/
values () {
return iterator.iteratorMap(createMapIterator(this), /** @param {any} v */ v => v[1].content.getContent()[v[1].length - 1])
@ -166,7 +191,7 @@ export class YMap extends AbstractType {
/**
* Returns an Iterator of [key, value] pairs
*
* @return {IterableIterator<[string, MapType]>}
* @return {IterableIterator<[StringKey<MapType>, MapType[StringKey<MapType>]]>}
*/
entries () {
return iterator.iteratorMap(createMapIterator(this), /** @param {any} v */ v => /** @type {any} */ ([v[0], v[1].content.getContent()[v[1].length - 1]]))
@ -175,13 +200,13 @@ export class YMap extends AbstractType {
/**
* Executes a provided function on once on every key-value pair.
*
* @param {function(MapType,string,YMap<MapType>):void} f A function to execute on every element of this YArray.
* @param {function(MapType[StringKey<MapType>],StringKey<MapType>,YMap<MapType>):void} f A function to execute on every element of this YArray.
*/
forEach (f) {
this.doc ?? warnPrematureAccess()
this._map.forEach((item, key) => {
if (!item.deleted) {
f(item.content.getContent()[item.length - 1], key, this)
f(item.content.getContent()[item.length - 1], /** @type {StringKey<MapType>} */ (key), this)
}
})
}
@ -189,7 +214,7 @@ export class YMap extends AbstractType {
/**
* Returns an Iterator of [key, value] pairs
*
* @return {IterableIterator<[string, MapType]>}
* @return {IterableIterator<[StringKey<MapType>, MapType[StringKey<MapType>]]>}
*/
[Symbol.iterator] () {
return this.entries()
@ -211,17 +236,18 @@ export class YMap extends AbstractType {
}
/**
* @template {StringKey<MapType>} Key
* @template {MapType[Key]} Value
* Adds or updates an element with a specified key and value.
* @template {MapType} VAL
*
* @param {string} key The key of the element to add to this YMap
* @param {VAL} value The value of the element to add
* @return {VAL}
* @param {Key} key The key of the element to add to this YMap
* @param {Value} value The value of the element to add
* @return {Value}
*/
set (key, value) {
if (this.doc !== null) {
transact(this.doc, transaction => {
typeMapSet(transaction, this, key, /** @type {any} */ (value))
typeMapSet(transaction, this, key, value)
})
} else {
/** @type {Map<string, any>} */ (this._prelimContent).set(key, value)
@ -230,13 +256,14 @@ export class YMap extends AbstractType {
}
/**
* @template {StringKey<MapType>} Key
* Returns a specified element from this YMap.
*
* @param {string} key
* @return {MapType|undefined}
* @param {Key} key
* @return {MapType[Key]|undefined}
*/
get (key) {
return /** @type {any} */ (typeMapGet(this, key))
return /** @type {MapType[Key]|undefined} */ (typeMapGet(this, key))
}
/**

View File

@ -268,14 +268,14 @@ export class Doc extends ObservableV2 {
}
/**
* @template T
* @template {Record<string, import("../internals.js").MapValue>} T
* @param {string} [name]
* @return {YMap<T>}
*
* @public
*/
getMap (name = '') {
return /** @type {YMap<T>} */ (this.get(name, YMap))
return /** @type {YMap<T>} */ /** @type {any} */ (this.get(name, YMap))
}
/**

View File

@ -114,6 +114,7 @@ export const testSubdoc = _tc => {
doc.on('subdocs', subdocs => {
event = [Array.from(subdocs.added).map(x => x.guid), Array.from(subdocs.removed).map(x => x.guid), Array.from(subdocs.loaded).map(x => x.guid)]
})
/** @type {Y.Map<{ a: Y.Doc; b: Y.Doc, c: Y.Doc; }>} */
const subdocs = doc.getMap('mysubdocs')
const docA = new Y.Doc({ guid: 'a' })
docA.load()
@ -121,18 +122,18 @@ export const testSubdoc = _tc => {
t.compare(event, [['a'], [], ['a']])
event = null
subdocs.get('a').load()
subdocs.get('a')?.load()
t.assert(event === null)
event = null
subdocs.get('a').destroy()
subdocs.get('a')?.destroy()
t.compare(event, [['a'], ['a'], []])
subdocs.get('a').load()
subdocs.get('a')?.load()
t.compare(event, [[], [], ['a']])
subdocs.set('b', new Y.Doc({ guid: 'a', shouldLoad: false }))
t.compare(event, [['a'], [], []])
subdocs.get('b').load()
subdocs.get('b')?.load()
t.compare(event, [[], [], ['a']])
const docC = new Y.Doc({ guid: 'c' })
@ -156,7 +157,9 @@ export const testSubdoc = _tc => {
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc))
t.compare(event, [['a', 'a', 'c'], [], []])
doc2.getMap('mysubdocs').get('a').load()
/** @type {Y.Map<Record<string, Y.Doc>>} */
const mysubdocs = doc2.getMap('mysubdocs')
mysubdocs.get('a')?.load()
t.compare(event, [[], [], ['a']])
t.compare(Array.from(doc2.getSubdocGuids()), ['a', 'c'])

92
tests/test-types.ts Normal file
View File

@ -0,0 +1,92 @@
/**
* This file serves to validate the public TypeScript API for YJS.
*
* It is not included in `npm run lint` or any other automated type checking, but can be used
* by those working on YJS types to ensure that the public-facing type interface remains valid.
*
* Any lines which are supposed to demonstrate statements that _would_ generate type errors
* should be clearly marked with the type error that is expected to result, to provide a
* negative test case.
*/
import * as Y from "../dist/src/index";
/*
* Typed maps
*
* - Key names are autocompleted in first parameter of `get` and `set`.
* - `MapType` value types are constrained to valid Y.Map contents.
*/
type MyType = {
foo: string;
bar: number | null;
baz?: boolean;
};
// Constructor argument keys & values are typechecked, and keys are autocompleted.
// Multiple items for each key and partial initialization are allowed.
const map = new Y.Map<MyType>([
["foo", ""],
["foo", "even better"],
// ERROR: Type '["baz", number]' is not assignable to type '["foo", string] | ["bar", number | null] | ["baz", boolean | undefined]'.
["baz", 3],
]);
// Entries are still allowed to be omitted, so get() still returns <type> | undefined.
const defaultMap = new Y.Map<MyType>();
// `json` has a type of `Partial<MyType>`
const json = defaultMap.toJSON();
// string | undefined
const fooValue = map.get("foo");
// literal "hi" (string)
const fooSet = map.set("foo", "hi");
// number | null | undefined
const barValue = map.get("bar");
// ERROR: Argument of type '"hi"' is not assignable to parameter of type 'number | null'.
const barSet = map.set("bar", "hi");
// ERROR: Argument of type '"bomb"' is not assignable to parameter of type 'keyof MyType'.
const missingGet = map.get("bomb");
// Escape hatch: get<any>()
const migrateGet = map.get<any>("extraneousKey");
// ERROR: Type '<type>' does not satisfy the constraint 'Record<string, MapValue>'.
const invalidMap = new Y.Map<{ invalid: () => void }>();
const invalidMap2 = new Y.Map<{ invalid: Blob }>();
// Arbitrarily complex valid types are still allowed
type ComplexType = {
n: null;
b: boolean;
s: string;
i: number;
u: Uint8Array;
a: null | boolean | string | number | Uint8Array[];
};
const complexValidType = new Y.Map<
ComplexType & { nested: ComplexType & { deeper: ComplexType[] } }
>();
/*
* Default behavior
*
* Provides basic typechecking over the range of possible map values.
*/
const untyped = new Y.Map();
// MapValue | undefined
const boop = untyped.get("default");
// Still validates value types: ERROR: Argument of type '() => string' is not assignable to parameter of type 'MapValue'.
const moop = untyped.set("anything", () => "whoops");
/*
* `any` maps (bypass typechecking)
*/
const anyMap = new Y.Map<any>();
// any
const fooValueAny = anyMap.get("foo");
// literal "hi" (string)
const fooSetAny = anyMap.set("foo", "hi");
// Allowed because `any` unlocks cowboy mode
const barSetAny = anyMap.set("bar", () => "hi");

View File

@ -435,6 +435,7 @@ export const testUndoUntilChangePerformed = _tc => {
const yMap = new Y.Map()
yMap.set('hello', 'world')
yArray.push([yMap])
/** @type {Y.Map<{ key: string }>} */
const yMap2 = new Y.Map()
yMap2.set('key', 'value')
yArray.push([yMap2])
@ -448,7 +449,7 @@ export const testUndoUntilChangePerformed = _tc => {
Y.transact(doc2, () => yArray2.delete(0), doc2.clientID)
undoManager2.undo()
undoManager.undo()
t.compareStrings(yMap2.get('key'), 'value')
t.compareStrings(yMap2.get('key') || '', 'value')
}
/**
@ -513,6 +514,7 @@ export const testUndoNestedUndoIssue = _tc => {
*/
export const testConsecutiveRedoBug = _tc => {
const doc = new Y.Doc()
/** @type {Y.Map<Record<string, Y.Map<any>>>} */
const yRoot = doc.getMap()
const undoMgr = new Y.UndoManager(yRoot)
@ -546,7 +548,7 @@ export const testConsecutiveRedoBug = _tc => {
t.compare(yRoot.get('a'), undefined)
undoMgr.redo() // x=0, y=0
yPoint = yRoot.get('a')
yPoint = yRoot.get('a') || new Y.Map()
t.compare(yPoint.toJSON(), { x: 0, y: 0 })
undoMgr.redo() // x=100, y=100

View File

@ -14,7 +14,7 @@ import * as prng from 'lib0/prng'
export const testIterators = _tc => {
const ydoc = new Y.Doc()
/**
* @type {Y.Map<number>}
* @type {Y.Map<Record<string, number>>}
*/
const ymap = ydoc.getMap()
// we are only checking if the type assumptions are correct
@ -67,14 +67,16 @@ export const testMapHavingIterableAsConstructorParamTests = tc => {
t.assert(m1.get('number') === 1)
t.assert(m1.get('string') === 'hello')
/** @type {Y.Map<{ object: { x: number; }; boolean: boolean; }>} */
const m2 = new Y.Map([
['object', { x: 1 }],
['boolean', true]
])
map0.set('m2', m2)
t.assert(m2.get('object').x === 1)
t.assert(m2.get('object')?.x === 1)
t.assert(m2.get('boolean') === true)
/** @type {Y.Map<any>} */
const m3 = new Y.Map([...m1, ...m2])
map0.set('m3', m3)
t.assert(m3.get('number') === 1)
@ -329,7 +331,7 @@ export const testGetAndSetAndDeleteOfMapPropertyWithThreeConflicts = tc => {
*/
export const testObserveDeepProperties = tc => {
const { testConnector, users, map1, map2, map3 } = init(tc, { users: 4 })
const _map1 = map1.set('map', new Y.Map())
const _map1 = map1.set('map', /** @type {Y.Map<{ deepmap: Y.Map<any> }>} */ (new Y.Map()))
let calls = 0
let dmapid
map1.observeDeep(events => {
@ -354,10 +356,10 @@ export const testObserveDeepProperties = tc => {
const dmap2 = _map2.get('deepmap')
const dmap3 = _map3.get('deepmap')
t.assert(calls > 0)
t.assert(compareIDs(dmap1._item.id, dmap2._item.id))
t.assert(compareIDs(dmap1._item.id, dmap3._item.id))
t.assert(compareIDs(dmap1?._item?.id || null, dmap2._item.id))
t.assert(compareIDs(dmap1?._item?.id || null, dmap3._item.id))
// @ts-ignore we want the possibility of dmapid being undefined
t.assert(compareIDs(dmap1._item.id, dmapid))
t.assert(compareIDs(dmap1?._item?.id || null, dmapid))
compare(users)
}