Faster validations + record reference stability at the same time (#2848)

This PR adds a validation mode whereby previous known-to-be-valid values
can be used to speed up the validation process itself. At the same time
it enables us to do fine-grained equality checking on records much more
quickly than by using something like lodash isEqual, and using that we
can prevent triggering effects for record updates that don't actually
alter any values in the store.

Here's some preliminary perf testing of average time spent in
`store.put()` during some common interactions

| task | before (ms) | after (ms) |
| ---- | ---- | ---- |
| drawing lines | 0.0403 | 0.0214 |
| drawing boxes | 0.0408 | 0.0348 |
| translating lines | 0.0352 | 0.0042 |
| translating boxes | 0.0051 | 0.0032 |
| rotating lines | 0.0312 | 0.0065 |
| rotating boxes | 0.0053 | 0.0035 |
| brush selecting boxes | 0.0200 | 0.0232 |
| traversal with shapes | 0.0130 | 0.0108 |
| traversal without shapes | 0.0201 | 0.0173 |

**traversal** means moving the camera and pointer around the canvas

#### Discussion

At the scale of hundredths of a millisecond these .put operations are so
fast that even if they became literally instantaneous the change would
not be human perceptible. That said, there is an overall marked
improvement here. Especially for dealing with draw shapes.

These figures are also mostly in line with expectations, aside from a
couple of things:

- I don't understand why the `brush selecting boxes` task got slower
after the change.
- I don't understand why the `traversal` tasks are slower than the
`translating boxes` task, both before and after. I would expect that
.putting shape records would be much slower than .putting pointer/camera
records (since the latter have fewer and simpler properties)

### Change Type

- [x] `patch` — Bug fix

### Test Plan

1. Add a step-by-step description of how to test your PR here.
2.

- [ ] Unit Tests
- [ ] End to end tests

### Release Notes

- Add a brief release note for your PR here.
This commit is contained in:
David Sheldrick 2024-02-20 12:35:25 +00:00 committed by GitHub
parent 50f77fe75c
commit 4a2040f92c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 1486 additions and 293 deletions

View file

@ -11,7 +11,7 @@
"scripts": { "scripts": {
"dev": "cross-env NODE_ENV=development wrangler dev --log-level info --persist-to tmp-assets", "dev": "cross-env NODE_ENV=development wrangler dev --log-level info --persist-to tmp-assets",
"test-ci": "lazy inherit --passWithNoTests", "test-ci": "lazy inherit --passWithNoTests",
"test": "yarn run -T jest", "test": "yarn run -T jest --passWithNoTests",
"test-coverage": "lazy inherit --passWithNoTests", "test-coverage": "lazy inherit --passWithNoTests",
"lint": "yarn run -T tsx ../../scripts/lint.ts" "lint": "yarn run -T tsx ../../scripts/lint.ts"
}, },

View file

@ -15,7 +15,7 @@ const SIZES = [
{ value: 'm', icon: 'size-medium' }, { value: 'm', icon: 'size-medium' },
{ value: 'l', icon: 'size-large' }, { value: 'l', icon: 'size-large' },
{ value: 'xl', icon: 'size-extra-large' }, { value: 'xl', icon: 'size-extra-large' },
] ] as const
// There's a guide at the bottom of this file! // There's a guide at the bottom of this file!

View file

@ -31,9 +31,12 @@ export const FilterStyleUi = track(function FilterStyleUi() {
onChange={(e) => { onChange={(e) => {
editor.batch(() => { editor.batch(() => {
if (editor.isIn('select')) { if (editor.isIn('select')) {
editor.setStyleForSelectedShapes(MyFilterStyle, e.target.value) editor.setStyleForSelectedShapes(
MyFilterStyle,
MyFilterStyle.validate(e.target.value)
)
} }
editor.setStyleForNextShapes(MyFilterStyle, e.target.value) editor.setStyleForNextShapes(MyFilterStyle, MyFilterStyle.validate(e.target.value))
}) })
}} }}
> >

View file

@ -55,7 +55,7 @@
"check-scripts": "tsx scripts/check-scripts.ts", "check-scripts": "tsx scripts/check-scripts.ts",
"api-check": "lazy api-check", "api-check": "lazy api-check",
"test-ci": "lazy test-ci", "test-ci": "lazy test-ci",
"test": "yarn run -T jest", "test": "lazy test",
"test-coverage": "lazy test-coverage && node scripts/offer-coverage.mjs", "test-coverage": "lazy test-coverage && node scripts/offer-coverage.mjs",
"e2e": "lazy e2e --filter='apps/examples'" "e2e": "lazy e2e --filter='apps/examples'"
}, },

View file

@ -32,6 +32,7 @@ import { Signal } from '@tldraw/state';
import { StoreSchema } from '@tldraw/store'; import { StoreSchema } from '@tldraw/store';
import { StoreSnapshot } from '@tldraw/store'; import { StoreSnapshot } from '@tldraw/store';
import { StyleProp } from '@tldraw/tlschema'; import { StyleProp } from '@tldraw/tlschema';
import { StylePropValue } from '@tldraw/tlschema';
import { TLArrowShape } from '@tldraw/tlschema'; import { TLArrowShape } from '@tldraw/tlschema';
import { TLArrowShapeArrowheadStyle } from '@tldraw/tlschema'; import { TLArrowShapeArrowheadStyle } from '@tldraw/tlschema';
import { TLAsset } from '@tldraw/tlschema'; import { TLAsset } from '@tldraw/tlschema';
@ -864,7 +865,7 @@ export class Editor extends EventEmitter<TLEventMap> {
setOpacityForSelectedShapes(opacity: number, historyOptions?: TLCommandHistoryOptions): this; setOpacityForSelectedShapes(opacity: number, historyOptions?: TLCommandHistoryOptions): this;
setSelectedShapes(shapes: TLShape[] | TLShapeId[], historyOptions?: TLCommandHistoryOptions): this; setSelectedShapes(shapes: TLShape[] | TLShapeId[], historyOptions?: TLCommandHistoryOptions): this;
setStyleForNextShapes<T>(style: StyleProp<T>, value: T, historyOptions?: TLCommandHistoryOptions): this; setStyleForNextShapes<T>(style: StyleProp<T>, value: T, historyOptions?: TLCommandHistoryOptions): this;
setStyleForSelectedShapes<T>(style: StyleProp<T>, value: T, historyOptions?: TLCommandHistoryOptions): this; setStyleForSelectedShapes<S extends StyleProp<any>>(style: S, value: StylePropValue<S>, historyOptions?: TLCommandHistoryOptions): this;
shapeUtils: { shapeUtils: {
readonly [K in string]?: ShapeUtil<TLUnknownShape>; readonly [K in string]?: ShapeUtil<TLUnknownShape>;
}; };
@ -884,7 +885,7 @@ export class Editor extends EventEmitter<TLEventMap> {
stretchShapes(shapes: TLShape[] | TLShapeId[], operation: 'horizontal' | 'vertical'): this; stretchShapes(shapes: TLShape[] | TLShapeId[], operation: 'horizontal' | 'vertical'): this;
// (undocumented) // (undocumented)
styleProps: { styleProps: {
[key: string]: Map<StyleProp<unknown>, string>; [key: string]: Map<StyleProp<any>, string>;
}; };
readonly textMeasure: TextManager; readonly textMeasure: TextManager;
toggleLock(shapes: TLShape[] | TLShapeId[]): this; toggleLock(shapes: TLShape[] | TLShapeId[]): this;
@ -1462,10 +1463,10 @@ export { react }
// @public // @public
export class ReadonlySharedStyleMap { export class ReadonlySharedStyleMap {
// (undocumented) // (undocumented)
[Symbol.iterator](): IterableIterator<[StyleProp<unknown>, SharedStyle<unknown>]>; [Symbol.iterator](): IterableIterator<[StyleProp<any>, SharedStyle<unknown>]>;
constructor(entries?: Iterable<[StyleProp<unknown>, SharedStyle<unknown>]>); constructor(entries?: Iterable<[StyleProp<unknown>, SharedStyle<unknown>]>);
// (undocumented) // (undocumented)
entries(): IterableIterator<[StyleProp<unknown>, SharedStyle<unknown>]>; entries(): IterableIterator<[StyleProp<any>, SharedStyle<unknown>]>;
// (undocumented) // (undocumented)
equals(other: ReadonlySharedStyleMap): boolean; equals(other: ReadonlySharedStyleMap): boolean;
// (undocumented) // (undocumented)
@ -1473,9 +1474,9 @@ export class ReadonlySharedStyleMap {
// (undocumented) // (undocumented)
getAsKnownValue<T>(prop: StyleProp<T>): T | undefined; getAsKnownValue<T>(prop: StyleProp<T>): T | undefined;
// (undocumented) // (undocumented)
keys(): IterableIterator<StyleProp<unknown>>; keys(): IterableIterator<StyleProp<any>>;
// @internal (undocumented) // @internal (undocumented)
protected map: Map<StyleProp<unknown>, SharedStyle<unknown>>; protected map: Map<StyleProp<any>, SharedStyle<unknown>>;
// (undocumented) // (undocumented)
get size(): number; get size(): number;
// (undocumented) // (undocumented)

View file

@ -17658,7 +17658,7 @@
"excerptTokens": [ "excerptTokens": [
{ {
"kind": "Content", "kind": "Content",
"text": "setStyleForSelectedShapes<T>(style: " "text": "setStyleForSelectedShapes<S extends "
}, },
{ {
"kind": "Reference", "kind": "Reference",
@ -17667,15 +17667,28 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": "<T>" "text": "<any>"
},
{
"kind": "Content",
"text": ">(style: "
},
{
"kind": "Content",
"text": "S"
}, },
{ {
"kind": "Content", "kind": "Content",
"text": ", value: " "text": ", value: "
}, },
{
"kind": "Reference",
"text": "StylePropValue",
"canonicalReference": "@tldraw/tlschema!StylePropValue:type"
},
{ {
"kind": "Content", "kind": "Content",
"text": "T" "text": "<S>"
}, },
{ {
"kind": "Content", "kind": "Content",
@ -17701,10 +17714,10 @@
], ],
"typeParameters": [ "typeParameters": [
{ {
"typeParameterName": "T", "typeParameterName": "S",
"constraintTokenRange": { "constraintTokenRange": {
"startIndex": 0, "startIndex": 1,
"endIndex": 0 "endIndex": 3
}, },
"defaultTypeTokenRange": { "defaultTypeTokenRange": {
"startIndex": 0, "startIndex": 0,
@ -17714,8 +17727,8 @@
], ],
"isStatic": false, "isStatic": false,
"returnTypeTokenRange": { "returnTypeTokenRange": {
"startIndex": 8, "startIndex": 11,
"endIndex": 9 "endIndex": 12
}, },
"releaseTag": "Public", "releaseTag": "Public",
"isProtected": false, "isProtected": false,
@ -17723,14 +17736,6 @@
"parameters": [ "parameters": [
{ {
"parameterName": "style", "parameterName": "style",
"parameterTypeTokenRange": {
"startIndex": 1,
"endIndex": 3
},
"isOptional": false
},
{
"parameterName": "value",
"parameterTypeTokenRange": { "parameterTypeTokenRange": {
"startIndex": 4, "startIndex": 4,
"endIndex": 5 "endIndex": 5
@ -17738,10 +17743,18 @@
"isOptional": false "isOptional": false
}, },
{ {
"parameterName": "historyOptions", "parameterName": "value",
"parameterTypeTokenRange": { "parameterTypeTokenRange": {
"startIndex": 6, "startIndex": 6,
"endIndex": 7 "endIndex": 8
},
"isOptional": false
},
{
"parameterName": "historyOptions",
"parameterTypeTokenRange": {
"startIndex": 9,
"endIndex": 10
}, },
"isOptional": true "isOptional": true
} }
@ -18263,7 +18276,7 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": "<unknown>, string>;\n }" "text": "<any>, string>;\n }"
}, },
{ {
"kind": "Content", "kind": "Content",
@ -28523,7 +28536,7 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": "<unknown>, " "text": "<any>, "
}, },
{ {
"kind": "Reference", "kind": "Reference",
@ -28632,7 +28645,7 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": "<unknown>, " "text": "<any>, "
}, },
{ {
"kind": "Reference", "kind": "Reference",
@ -28872,7 +28885,7 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": "<unknown>>" "text": "<any>>"
}, },
{ {
"kind": "Content", "kind": "Content",

View file

@ -5,6 +5,7 @@ import {
InstancePageStateRecordType, InstancePageStateRecordType,
PageRecordType, PageRecordType,
StyleProp, StyleProp,
StylePropValue,
TLArrowShape, TLArrowShape,
TLAsset, TLAsset,
TLAssetId, TLAssetId,
@ -737,7 +738,7 @@ export class Editor extends EventEmitter<TLEventMap> {
*/ */
shapeUtils: { readonly [K in string]?: ShapeUtil<TLUnknownShape> } shapeUtils: { readonly [K in string]?: ShapeUtil<TLUnknownShape> }
styleProps: { [key: string]: Map<StyleProp<unknown>, string> } styleProps: { [key: string]: Map<StyleProp<any>, string> }
/** /**
* Get a shape util from a shape itself. * Get a shape util from a shape itself.
@ -7385,9 +7386,9 @@ export class Editor extends EventEmitter<TLEventMap> {
* *
* @public * @public
*/ */
setStyleForSelectedShapes<T>( setStyleForSelectedShapes<S extends StyleProp<any>>(
style: StyleProp<T>, style: S,
value: T, value: StylePropValue<S>,
historyOptions?: TLCommandHistoryOptions historyOptions?: TLCommandHistoryOptions
): this { ): this {
const selectedShapes = this.getSelectedShapes() const selectedShapes = this.getSelectedShapes()

View file

@ -35,7 +35,7 @@ function sharedStyleEquals<T>(a: SharedStyle<T>, b: SharedStyle<T> | undefined)
*/ */
export class ReadonlySharedStyleMap { export class ReadonlySharedStyleMap {
/** @internal */ /** @internal */
protected map: Map<StyleProp<unknown>, SharedStyle<unknown>> protected map: Map<StyleProp<any>, SharedStyle<unknown>>
constructor(entries?: Iterable<[StyleProp<unknown>, SharedStyle<unknown>]>) { constructor(entries?: Iterable<[StyleProp<unknown>, SharedStyle<unknown>]>) {
this.map = new Map(entries) this.map = new Map(entries)

View file

@ -163,9 +163,7 @@ export class RecordType<R extends UnknownRecord, RequiredProperties extends keyo
typeName: R['typeName'], config: { typeName: R['typeName'], config: {
readonly createDefaultProperties: () => Exclude<OmitMeta<R>, RequiredProperties>; readonly createDefaultProperties: () => Exclude<OmitMeta<R>, RequiredProperties>;
readonly migrations: Migrations; readonly migrations: Migrations;
readonly validator?: { readonly validator?: StoreValidator<R>;
validate: (r: unknown) => R;
} | StoreValidator<R>;
readonly scope?: RecordScope; readonly scope?: RecordScope;
}); });
clone(record: R): R; clone(record: R): R;
@ -183,11 +181,9 @@ export class RecordType<R extends UnknownRecord, RequiredProperties extends keyo
// (undocumented) // (undocumented)
readonly scope: RecordScope; readonly scope: RecordScope;
readonly typeName: R['typeName']; readonly typeName: R['typeName'];
validate(record: unknown): R; validate(record: unknown, recordBefore?: R): R;
// (undocumented) // (undocumented)
readonly validator: { readonly validator: StoreValidator<R>;
validate: (r: unknown) => R;
} | StoreValidator<R>;
withDefaultProperties<DefaultProps extends Omit<Partial<R>, 'id' | 'typeName'>>(createDefaultProperties: () => DefaultProps): RecordType<R, Exclude<RequiredProperties, keyof DefaultProps>>; withDefaultProperties<DefaultProps extends Omit<Partial<R>, 'id' | 'typeName'>>(createDefaultProperties: () => DefaultProps): RecordType<R, Exclude<RequiredProperties, keyof DefaultProps>>;
} }
@ -344,6 +340,7 @@ export type StoreSnapshot<R extends UnknownRecord> = {
// @public (undocumented) // @public (undocumented)
export type StoreValidator<R extends UnknownRecord> = { export type StoreValidator<R extends UnknownRecord> = {
validate: (record: unknown) => R; validate: (record: unknown) => R;
validateUsingKnownGoodVersion?: (knownGoodVersion: R, record: unknown) => R;
}; };
// @public (undocumented) // @public (undocumented)

View file

@ -2062,7 +2062,7 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": ";\n readonly validator?: {\n validate: (r: unknown) => R;\n } | " "text": ";\n readonly validator?: "
}, },
{ {
"kind": "Reference", "kind": "Reference",
@ -2650,6 +2650,14 @@
"kind": "Content", "kind": "Content",
"text": "unknown" "text": "unknown"
}, },
{
"kind": "Content",
"text": ", recordBefore?: "
},
{
"kind": "Content",
"text": "R"
},
{ {
"kind": "Content", "kind": "Content",
"text": "): " "text": "): "
@ -2665,8 +2673,8 @@
], ],
"isStatic": false, "isStatic": false,
"returnTypeTokenRange": { "returnTypeTokenRange": {
"startIndex": 3, "startIndex": 5,
"endIndex": 4 "endIndex": 6
}, },
"releaseTag": "Public", "releaseTag": "Public",
"isProtected": false, "isProtected": false,
@ -2679,6 +2687,14 @@
"endIndex": 2 "endIndex": 2
}, },
"isOptional": false "isOptional": false
},
{
"parameterName": "recordBefore",
"parameterTypeTokenRange": {
"startIndex": 3,
"endIndex": 4
},
"isOptional": true
} }
], ],
"isOptional": false, "isOptional": false,
@ -2694,10 +2710,6 @@
"kind": "Content", "kind": "Content",
"text": "readonly validator: " "text": "readonly validator: "
}, },
{
"kind": "Content",
"text": "{\n validate: (r: unknown) => R;\n } | "
},
{ {
"kind": "Reference", "kind": "Reference",
"text": "StoreValidator", "text": "StoreValidator",
@ -2718,7 +2730,7 @@
"name": "validator", "name": "validator",
"propertyTypeTokenRange": { "propertyTypeTokenRange": {
"startIndex": 1, "startIndex": 1,
"endIndex": 4 "endIndex": 3
}, },
"isStatic": false, "isStatic": false,
"isProtected": false, "isProtected": false,
@ -5535,7 +5547,7 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": "{\n validate: (record: unknown) => R;\n}" "text": "{\n validate: (record: unknown) => R;\n validateUsingKnownGoodVersion?: (knownGoodVersion: R, record: unknown) => R;\n}"
}, },
{ {
"kind": "Content", "kind": "Content",

View file

@ -29,7 +29,7 @@ export class RecordType<
> { > {
readonly createDefaultProperties: () => Exclude<OmitMeta<R>, RequiredProperties> readonly createDefaultProperties: () => Exclude<OmitMeta<R>, RequiredProperties>
readonly migrations: Migrations readonly migrations: Migrations
readonly validator: StoreValidator<R> | { validate: (r: unknown) => R } readonly validator: StoreValidator<R>
readonly scope: RecordScope readonly scope: RecordScope
@ -44,7 +44,7 @@ export class RecordType<
config: { config: {
readonly createDefaultProperties: () => Exclude<OmitMeta<R>, RequiredProperties> readonly createDefaultProperties: () => Exclude<OmitMeta<R>, RequiredProperties>
readonly migrations: Migrations readonly migrations: Migrations
readonly validator?: StoreValidator<R> | { validate: (r: unknown) => R } readonly validator?: StoreValidator<R>
readonly scope?: RecordScope readonly scope?: RecordScope
} }
) { ) {
@ -198,7 +198,10 @@ export class RecordType<
* Check that the passed in record passes the validations for this type. Returns its input * Check that the passed in record passes the validations for this type. Returns its input
* correctly typed if it does, but throws an error otherwise. * correctly typed if it does, but throws an error otherwise.
*/ */
validate(record: unknown): R { validate(record: unknown, recordBefore?: R): R {
if (recordBefore && this.validator.validateUsingKnownGoodVersion) {
return this.validator.validateUsingKnownGoodVersion(recordBefore, record)
}
return this.validator.validate(record) return this.validator.validate(record)
} }
} }

View file

@ -84,6 +84,7 @@ export type StoreSnapshot<R extends UnknownRecord> = {
/** @public */ /** @public */
export type StoreValidator<R extends UnknownRecord> = { export type StoreValidator<R extends UnknownRecord> = {
validate: (record: unknown) => R validate: (record: unknown) => R
validateUsingKnownGoodVersion?: (knownGoodVersion: R, record: unknown) => R
} }
/** @public */ /** @public */
@ -389,24 +390,19 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
if (beforeUpdate) record = beforeUpdate(initialValue, record, source) if (beforeUpdate) record = beforeUpdate(initialValue, record, source)
// Validate the record // Validate the record
record = this.schema.validateRecord( const validated = this.schema.validateRecord(
this, this,
record, record,
phaseOverride ?? 'updateRecord', phaseOverride ?? 'updateRecord',
initialValue initialValue
) )
if (validated === initialValue) continue
recordAtom.set(devFreeze(record)) recordAtom.set(devFreeze(record))
// need to deref atom in case nextValue is not identical but is .equals? didChange = true
const finalValue = recordAtom.__unsafe__getWithoutCapture() updates[record.id] = [initialValue, recordAtom.__unsafe__getWithoutCapture()]
// If the value has changed, assign it to updates.
// todo: is this always going to be true?
if (initialValue !== finalValue) {
didChange = true
updates[record.id] = [initialValue, finalValue]
}
} else { } else {
if (beforeCreate) record = beforeCreate(record, source) if (beforeCreate) record = beforeCreate(record, source)

View file

@ -85,7 +85,7 @@ export class StoreSchema<R extends UnknownRecord, P = unknown> {
if (!recordType) { if (!recordType) {
throw new Error(`Missing definition for record type ${record.typeName}`) throw new Error(`Missing definition for record type ${record.typeName}`)
} }
return recordType.validate(record) return recordType.validate(record, recordBefore ?? undefined)
} catch (error: unknown) { } catch (error: unknown) {
if (this.options.onValidationFailure) { if (this.options.onValidationFailure) {
return this.options.onValidationFailure({ return this.options.onValidationFailure({

View file

@ -195,9 +195,9 @@ export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
isPrecise: boolean; isPrecise: boolean;
}>; }>;
point: ObjectValidator< { point: ObjectValidator< {
type: "point";
x: number; x: number;
y: number; y: number;
type: "point";
}>; }>;
}, never>; }, never>;
end: UnionValidator<"type", { end: UnionValidator<"type", {
@ -209,9 +209,9 @@ export class ArrowShapeUtil extends ShapeUtil<TLArrowShape> {
isPrecise: boolean; isPrecise: boolean;
}>; }>;
point: ObjectValidator< { point: ObjectValidator< {
type: "point";
x: number; x: number;
y: number; y: number;
type: "point";
}>; }>;
}, never>; }, never>;
bend: Validator<number>; bend: Validator<number>;
@ -1253,7 +1253,7 @@ export function TldrawUiButtonIcon({ icon, small, invertIcon }: TLUiButtonIconPr
export function TldrawUiButtonLabel({ children }: TLUiButtonLabelProps): JSX_2.Element; export function TldrawUiButtonLabel({ children }: TLUiButtonLabelProps): JSX_2.Element;
// @public (undocumented) // @public (undocumented)
export const TldrawUiButtonPicker: MemoExoticComponent<(<T extends string>(props: TLUiButtonPickerProps<T>) => JSX_2.Element)>; export const TldrawUiButtonPicker: typeof _TldrawUiButtonPicker;
// @public (undocumented) // @public (undocumented)
export function TldrawUiComponentsProvider({ overrides, children, }: TLUiComponentsProviderProps): JSX_2.Element; export function TldrawUiComponentsProvider({ overrides, children, }: TLUiComponentsProviderProps): JSX_2.Element;
@ -2108,7 +2108,7 @@ export function useNativeClipboardEvents(): void;
export function useReadonly(): boolean; export function useReadonly(): boolean;
// @public (undocumented) // @public (undocumented)
export function useRelevantStyles(stylesToCheck?: readonly (EnumStyleProp<"black" | "blue" | "green" | "grey" | "light-blue" | "light-green" | "light-red" | "light-violet" | "orange" | "red" | "violet" | "yellow"> | EnumStyleProp<"dashed" | "dotted" | "draw" | "solid"> | EnumStyleProp<"l" | "m" | "s" | "xl"> | EnumStyleProp<"none" | "pattern" | "semi" | "solid">)[]): null | ReadonlySharedStyleMap; export function useRelevantStyles(stylesToCheck?: readonly StyleProp<any>[]): null | ReadonlySharedStyleMap;
// @public (undocumented) // @public (undocumented)
export function useTldrawUiComponents(): Partial<{ export function useTldrawUiComponents(): Partial<{

View file

@ -1387,7 +1387,7 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": "<{\n type: \"point\";\n x: number;\n y: number;\n }>;\n }, never>;\n end: import(\"@tldraw/editor\")." "text": "<{\n x: number;\n y: number;\n type: \"point\";\n }>;\n }, never>;\n end: import(\"@tldraw/editor\")."
}, },
{ {
"kind": "Reference", "kind": "Reference",
@ -1432,7 +1432,7 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": "<{\n type: \"point\";\n x: number;\n y: number;\n }>;\n }, never>;\n bend: import(\"@tldraw/editor\")." "text": "<{\n x: number;\n y: number;\n type: \"point\";\n }>;\n }, never>;\n bend: import(\"@tldraw/editor\")."
}, },
{ {
"kind": "Reference", "kind": "Reference",
@ -14716,34 +14716,12 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": "import(\"react\")." "text": "typeof "
}, },
{ {
"kind": "Reference", "kind": "Reference",
"text": "MemoExoticComponent", "text": "_TldrawUiButtonPicker",
"canonicalReference": "@types/react!React.MemoExoticComponent:type" "canonicalReference": "@tldraw/tldraw!~_TldrawUiButtonPicker:function"
},
{
"kind": "Content",
"text": "<(<T extends string>(props: "
},
{
"kind": "Reference",
"text": "TLUiButtonPickerProps",
"canonicalReference": "@tldraw/tldraw!TLUiButtonPickerProps:interface"
},
{
"kind": "Content",
"text": "<T>) => import(\"react/jsx-runtime\")."
},
{
"kind": "Reference",
"text": "JSX.Element",
"canonicalReference": "@types/react!JSX.Element:interface"
},
{
"kind": "Content",
"text": ")>"
} }
], ],
"fileUrlPath": "packages/tldraw/src/lib/ui/components/primitives/TldrawUiButtonPicker.tsx", "fileUrlPath": "packages/tldraw/src/lib/ui/components/primitives/TldrawUiButtonPicker.tsx",
@ -14752,7 +14730,7 @@
"name": "TldrawUiButtonPicker", "name": "TldrawUiButtonPicker",
"variableTypeTokenRange": { "variableTypeTokenRange": {
"startIndex": 1, "startIndex": 1,
"endIndex": 8 "endIndex": 3
} }
}, },
{ {
@ -23506,43 +23484,16 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": "readonly (import(\"@tldraw/editor\")." "text": "readonly "
}, },
{ {
"kind": "Reference", "kind": "Reference",
"text": "EnumStyleProp", "text": "StyleProp",
"canonicalReference": "@tldraw/tlschema!EnumStyleProp:class" "canonicalReference": "@tldraw/tlschema!StyleProp:class"
}, },
{ {
"kind": "Content", "kind": "Content",
"text": "<\"black\" | \"blue\" | \"green\" | \"grey\" | \"light-blue\" | \"light-green\" | \"light-red\" | \"light-violet\" | \"orange\" | \"red\" | \"violet\" | \"yellow\"> | import(\"@tldraw/editor\")." "text": "<any>[]"
},
{
"kind": "Reference",
"text": "EnumStyleProp",
"canonicalReference": "@tldraw/tlschema!EnumStyleProp:class"
},
{
"kind": "Content",
"text": "<\"dashed\" | \"dotted\" | \"draw\" | \"solid\"> | import(\"@tldraw/editor\")."
},
{
"kind": "Reference",
"text": "EnumStyleProp",
"canonicalReference": "@tldraw/tlschema!EnumStyleProp:class"
},
{
"kind": "Content",
"text": "<\"l\" | \"m\" | \"s\" | \"xl\"> | import(\"@tldraw/editor\")."
},
{
"kind": "Reference",
"text": "EnumStyleProp",
"canonicalReference": "@tldraw/tlschema!EnumStyleProp:class"
},
{
"kind": "Content",
"text": "<\"none\" | \"pattern\" | \"semi\" | \"solid\">)[]"
}, },
{ {
"kind": "Content", "kind": "Content",
@ -23564,8 +23515,8 @@
], ],
"fileUrlPath": "packages/tldraw/src/lib/ui/hooks/useRevelantStyles.ts", "fileUrlPath": "packages/tldraw/src/lib/ui/hooks/useRevelantStyles.ts",
"returnTypeTokenRange": { "returnTypeTokenRange": {
"startIndex": 11, "startIndex": 5,
"endIndex": 13 "endIndex": 7
}, },
"releaseTag": "Public", "releaseTag": "Public",
"overloadIndex": 1, "overloadIndex": 1,
@ -23574,7 +23525,7 @@
"parameterName": "stylesToCheck", "parameterName": "stylesToCheck",
"parameterTypeTokenRange": { "parameterTypeTokenRange": {
"startIndex": 1, "startIndex": 1,
"endIndex": 10 "endIndex": 4
}, },
"isOptional": true "isOptional": true
} }

View file

@ -12,6 +12,8 @@ import {
LineShapeSplineStyle, LineShapeSplineStyle,
ReadonlySharedStyleMap, ReadonlySharedStyleMap,
StyleProp, StyleProp,
TLArrowShapeArrowheadStyle,
TLDefaultVerticalAlignStyle,
minBy, minBy,
useEditor, useEditor,
useValue, useValue,
@ -199,7 +201,7 @@ function TextStylePickerSet({ styles }: { styles: ReadonlySharedStyleMap }) {
<TldrawUiButtonIcon icon="vertical-align-center" /> <TldrawUiButtonIcon icon="vertical-align-center" />
</TldrawUiButton> </TldrawUiButton>
) : ( ) : (
<DropdownPicker <DropdownPicker<TLDefaultVerticalAlignStyle>
type="icon" type="icon"
id="geo-vertical-alignment" id="geo-vertical-alignment"
uiType="verticalAlign" uiType="verticalAlign"
@ -270,7 +272,7 @@ function ArrowheadStylePickerSet({ styles }: { styles: ReadonlySharedStyleMap })
} }
return ( return (
<DoubleDropdownPicker <DoubleDropdownPicker<TLArrowShapeArrowheadStyle>
label={'style-panel.arrowheads'} label={'style-panel.arrowheads'}
uiTypeA="arrowheadStart" uiTypeA="arrowheadStart"
styleA={ArrowShapeArrowheadStartStyle} styleA={ArrowShapeArrowheadStartStyle}

View file

@ -27,7 +27,7 @@ interface DoubleDropdownPickerProps<T extends string> {
onValueChange: (style: StyleProp<T>, value: T, squashing: boolean) => void onValueChange: (style: StyleProp<T>, value: T, squashing: boolean) => void
} }
export const DoubleDropdownPicker = React.memo(function DoubleDropdownPicker<T extends string>({ function _DoubleDropdownPicker<T extends string>({
label, label,
uiTypeA, uiTypeA,
uiTypeB, uiTypeB,
@ -137,4 +137,9 @@ export const DoubleDropdownPicker = React.memo(function DoubleDropdownPicker<T e
</div> </div>
</div> </div>
) )
}) }
// need to memo like this to get generics
export const DoubleDropdownPicker = React.memo(
_DoubleDropdownPicker
) as typeof _DoubleDropdownPicker

View file

@ -25,7 +25,7 @@ interface DropdownPickerProps<T extends string> {
onValueChange: (style: StyleProp<T>, value: T, squashing: boolean) => void onValueChange: (style: StyleProp<T>, value: T, squashing: boolean) => void
} }
export const DropdownPicker = React.memo(function DropdownPicker<T extends string>({ function _DropdownPicker<T extends string>({
id, id,
label, label,
uiType, uiType,
@ -76,4 +76,7 @@ export const DropdownPicker = React.memo(function DropdownPicker<T extends strin
</TldrawUiDropdownMenuContent> </TldrawUiDropdownMenuContent>
</TldrawUiDropdownMenuRoot> </TldrawUiDropdownMenuRoot>
) )
}) }
// need to export like this to get generics
export const DropdownPicker = React.memo(_DropdownPicker) as typeof _DropdownPicker

View file

@ -25,10 +25,7 @@ export interface TLUiButtonPickerProps<T extends string> {
onValueChange: (style: StyleProp<T>, value: T, squashing: boolean) => void onValueChange: (style: StyleProp<T>, value: T, squashing: boolean) => void
} }
/** @public */ function _TldrawUiButtonPicker<T extends string>(props: TLUiButtonPickerProps<T>) {
export const TldrawUiButtonPicker = memo(function TldrawUiButtonPicker<T extends string>(
props: TLUiButtonPickerProps<T>
) {
const { const {
uiType, uiType,
items, items,
@ -125,4 +122,6 @@ export const TldrawUiButtonPicker = memo(function TldrawUiButtonPicker<T extends
))} ))}
</div> </div>
) )
}) }
/** @public */
export const TldrawUiButtonPicker = memo(_TldrawUiButtonPicker) as typeof _TldrawUiButtonPicker

View file

@ -5,11 +5,12 @@ import {
DefaultSizeStyle, DefaultSizeStyle,
ReadonlySharedStyleMap, ReadonlySharedStyleMap,
SharedStyleMap, SharedStyleMap,
StyleProp,
useEditor, useEditor,
useValue, useValue,
} from '@tldraw/editor' } from '@tldraw/editor'
const selectToolStyles = Object.freeze([ const selectToolStyles: readonly StyleProp<any>[] = Object.freeze([
DefaultColorStyle, DefaultColorStyle,
DefaultDashStyle, DefaultDashStyle,
DefaultFillStyle, DefaultFillStyle,

View file

@ -56,7 +56,7 @@ const tldrawFileValidator: T.Validator<TldrawFile> = T.object({
}), }),
records: T.arrayOf( records: T.arrayOf(
T.object({ T.object({
id: T.string as T.Validator<RecordId<any>>, id: T.string as any as T.Validator<RecordId<any>>,
typeName: T.string, typeName: T.string,
}).allowUnknownProperties() }).allowUnknownProperties()
), ),

View file

@ -45,12 +45,12 @@ export const arrowShapeProps: {
normalizedAnchor: VecModel; normalizedAnchor: VecModel;
isExact: boolean; isExact: boolean;
isPrecise: boolean; isPrecise: boolean;
}>; } & {}>;
point: T.ObjectValidator<{ point: T.ObjectValidator<{
type: "point";
x: number; x: number;
y: number; y: number;
}>; type: "point";
} & {}>;
}, never>; }, never>;
end: T.UnionValidator<"type", { end: T.UnionValidator<"type", {
binding: T.ObjectValidator<{ binding: T.ObjectValidator<{
@ -59,12 +59,12 @@ export const arrowShapeProps: {
normalizedAnchor: VecModel; normalizedAnchor: VecModel;
isExact: boolean; isExact: boolean;
isPrecise: boolean; isPrecise: boolean;
}>; } & {}>;
point: T.ObjectValidator<{ point: T.ObjectValidator<{
type: "point";
x: number; x: number;
y: number; y: number;
}>; type: "point";
} & {}>;
}, never>; }, never>;
bend: T.Validator<number>; bend: T.Validator<number>;
text: T.Validator<string>; text: T.Validator<string>;
@ -116,13 +116,19 @@ export const CameraRecordType: RecordType<TLCamera, never>;
export const canvasUiColorTypeValidator: T.Validator<"accent" | "black" | "laser" | "muted-1" | "selection-fill" | "selection-stroke" | "white">; export const canvasUiColorTypeValidator: T.Validator<"accent" | "black" | "laser" | "muted-1" | "selection-fill" | "selection-stroke" | "white">;
// @public // @public
export function createAssetValidator<Type extends string, Props extends JsonObject>(type: Type, props: T.Validator<Props>): T.ObjectValidator<{ export function createAssetValidator<Type extends string, Props extends JsonObject>(type: Type, props: T.Validator<Props>): T.ObjectValidator<{ [P in "id" | "meta" | "typeName" | (undefined extends Props ? never : "props") | (undefined extends Type ? never : "type")]: {
id: TLAssetId; id: TLAssetId;
typeName: 'asset'; typeName: 'asset';
type: Type; type: Type;
props: Props; props: Props;
meta: JsonObject; meta: JsonObject;
}>; }[P]; } & { [P_1 in (undefined extends Props ? "props" : never) | (undefined extends Type ? "type" : never)]?: {
id: TLAssetId;
typeName: 'asset';
type: Type;
props: Props;
meta: JsonObject;
}[P_1] | undefined; }>;
// @public (undocumented) // @public (undocumented)
export const createPresenceStateDerivation: ($user: Signal<{ export const createPresenceStateDerivation: ($user: Signal<{
@ -139,7 +145,7 @@ export function createShapeValidator<Type extends string, Props extends JsonObje
[K in keyof Props]: T.Validatable<Props[K]>; [K in keyof Props]: T.Validatable<Props[K]>;
}, meta?: { }, meta?: {
[K in keyof Meta]: T.Validatable<Meta[K]>; [K in keyof Meta]: T.Validatable<Meta[K]>;
}): T.ObjectValidator<TLBaseShape<Type, Props>>; }): T.ObjectValidator<{ [P in "id" | "index" | "isLocked" | "meta" | "opacity" | "parentId" | "rotation" | "typeName" | "x" | "y" | (undefined extends Props ? never : "props") | (undefined extends Type ? never : "type")]: TLBaseShape<Type, Props>[P]; } & { [P_1 in (undefined extends Props ? "props" : never) | (undefined extends Type ? "type" : never)]?: TLBaseShape<Type, Props>[P_1] | undefined; }>;
// @public // @public
export function createTLSchema({ shapes }: { export function createTLSchema({ shapes }: {
@ -196,7 +202,7 @@ export const drawShapeProps: {
segments: T.ArrayOfValidator<{ segments: T.ArrayOfValidator<{
type: "free" | "straight"; type: "free" | "straight";
points: VecModel[]; points: VecModel[];
}>; } & {}>;
isComplete: T.Validator<boolean>; isComplete: T.Validator<boolean>;
isClosed: T.Validator<boolean>; isClosed: T.Validator<boolean>;
isPen: T.Validator<boolean>; isPen: T.Validator<boolean>;
@ -515,7 +521,7 @@ export const highlightShapeProps: {
segments: T.ArrayOfValidator<{ segments: T.ArrayOfValidator<{
type: "free" | "straight"; type: "free" | "straight";
points: VecModel[]; points: VecModel[];
}>; } & {}>;
isComplete: T.Validator<boolean>; isComplete: T.Validator<boolean>;
isPen: T.Validator<boolean>; isPen: T.Validator<boolean>;
}; };
@ -533,10 +539,10 @@ export const imageShapeProps: {
playing: T.Validator<boolean>; playing: T.Validator<boolean>;
url: T.Validator<string>; url: T.Validator<string>;
assetId: T.Validator<TLAssetId | null>; assetId: T.Validator<TLAssetId | null>;
crop: T.Validator<{ crop: T.Validator<({
topLeft: VecModel; topLeft: VecModel;
bottomRight: VecModel; bottomRight: VecModel;
} | null>; } & {}) | null>;
}; };
// @public (undocumented) // @public (undocumented)
@ -716,12 +722,8 @@ export const rootShapeMigrations: Migrations;
// @public (undocumented) // @public (undocumented)
export type SchemaShapeInfo = { export type SchemaShapeInfo = {
migrations?: Migrations; migrations?: Migrations;
props?: Record<string, { props?: Record<string, AnyValidator>;
validate: (prop: any) => any; meta?: Record<string, AnyValidator>;
}>;
meta?: Record<string, {
validate: (prop: any) => any;
}>;
}; };
// @internal (undocumented) // @internal (undocumented)
@ -755,8 +757,13 @@ export class StyleProp<Type> implements T.Validatable<Type> {
readonly type: T.Validatable<Type>; readonly type: T.Validatable<Type>;
// (undocumented) // (undocumented)
validate(value: unknown): Type; validate(value: unknown): Type;
// (undocumented)
validateUsingKnownGoodVersion(prevValue: Type, newValue: unknown): Type;
} }
// @public (undocumented)
export type StylePropValue<T extends StyleProp<any>> = T extends StyleProp<infer U> ? U : never;
// @internal (undocumented) // @internal (undocumented)
export const textShapeMigrations: Migrations; export const textShapeMigrations: Migrations;

View file

@ -355,7 +355,7 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": ";\n isExact: boolean;\n isPrecise: boolean;\n }>;\n point: " "text": ";\n isExact: boolean;\n isPrecise: boolean;\n } & {}>;\n point: "
}, },
{ {
"kind": "Reference", "kind": "Reference",
@ -364,7 +364,7 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": "<{\n type: \"point\";\n x: number;\n y: number;\n }>;\n }, never>;\n end: " "text": "<{\n x: number;\n y: number;\n type: \"point\";\n } & {}>;\n }, never>;\n end: "
}, },
{ {
"kind": "Reference", "kind": "Reference",
@ -400,7 +400,7 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": ";\n isExact: boolean;\n isPrecise: boolean;\n }>;\n point: " "text": ";\n isExact: boolean;\n isPrecise: boolean;\n } & {}>;\n point: "
}, },
{ {
"kind": "Reference", "kind": "Reference",
@ -409,7 +409,7 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": "<{\n type: \"point\";\n x: number;\n y: number;\n }>;\n }, never>;\n bend: " "text": "<{\n x: number;\n y: number;\n type: \"point\";\n } & {}>;\n }, never>;\n bend: "
}, },
{ {
"kind": "Reference", "kind": "Reference",
@ -880,7 +880,7 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": "<{\n id: " "text": "<{ [P in \"id\" | \"meta\" | \"typeName\" | (undefined extends Props ? never : \"props\") | (undefined extends Type ? never : \"type\")]: {\n id: "
}, },
{ {
"kind": "Reference", "kind": "Reference",
@ -898,7 +898,25 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": ";\n}>" "text": ";\n}[P]; } & { [P_1 in (undefined extends Props ? \"props\" : never) | (undefined extends Type ? \"type\" : never)]?: {\n id: "
},
{
"kind": "Reference",
"text": "TLAssetId",
"canonicalReference": "@tldraw/tlschema!TLAssetId:type"
},
{
"kind": "Content",
"text": ";\n typeName: 'asset';\n type: Type;\n props: Props;\n meta: "
},
{
"kind": "Reference",
"text": "JsonObject",
"canonicalReference": "@tldraw/utils!JsonObject:type"
},
{
"kind": "Content",
"text": ";\n}[P_1] | undefined; }>"
}, },
{ {
"kind": "Content", "kind": "Content",
@ -908,7 +926,7 @@
"fileUrlPath": "packages/tlschema/src/assets/TLBaseAsset.ts", "fileUrlPath": "packages/tlschema/src/assets/TLBaseAsset.ts",
"returnTypeTokenRange": { "returnTypeTokenRange": {
"startIndex": 10, "startIndex": 10,
"endIndex": 16 "endIndex": 20
}, },
"releaseTag": "Public", "releaseTag": "Public",
"overloadIndex": 1, "overloadIndex": 1,
@ -1154,7 +1172,7 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": "<" "text": "<{ [P in \"id\" | \"index\" | \"isLocked\" | \"meta\" | \"opacity\" | \"parentId\" | \"rotation\" | \"typeName\" | \"x\" | \"y\" | (undefined extends Props ? never : \"props\") | (undefined extends Type ? never : \"type\")]: "
}, },
{ {
"kind": "Reference", "kind": "Reference",
@ -1163,7 +1181,16 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": "<Type, Props>>" "text": "<Type, Props>[P]; } & { [P_1 in (undefined extends Props ? \"props\" : never) | (undefined extends Type ? \"type\" : never)]?: "
},
{
"kind": "Reference",
"text": "TLBaseShape",
"canonicalReference": "@tldraw/tlschema!TLBaseShape:interface"
},
{
"kind": "Content",
"text": "<Type, Props>[P_1] | undefined; }>"
}, },
{ {
"kind": "Content", "kind": "Content",
@ -1173,7 +1200,7 @@
"fileUrlPath": "packages/tlschema/src/shapes/TLBaseShape.ts", "fileUrlPath": "packages/tlschema/src/shapes/TLBaseShape.ts",
"returnTypeTokenRange": { "returnTypeTokenRange": {
"startIndex": 17, "startIndex": 17,
"endIndex": 21 "endIndex": 23
}, },
"releaseTag": "Public", "releaseTag": "Public",
"overloadIndex": 1, "overloadIndex": 1,
@ -1698,7 +1725,7 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": "[];\n }>;\n isComplete: " "text": "[];\n } & {}>;\n isComplete: "
}, },
{ {
"kind": "Reference", "kind": "Reference",
@ -2304,7 +2331,7 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": "[];\n }>;\n isComplete: " "text": "[];\n } & {}>;\n isComplete: "
}, },
{ {
"kind": "Reference", "kind": "Reference",
@ -2408,7 +2435,7 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": "<{\n topLeft: import(\"../misc/geometry-types\")." "text": "<({\n topLeft: import(\"../misc/geometry-types\")."
}, },
{ {
"kind": "Reference", "kind": "Reference",
@ -2426,7 +2453,7 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": ";\n } | null>;\n}" "text": ";\n } & {}) | null>;\n}"
} }
], ],
"fileUrlPath": "packages/tlschema/src/shapes/TLImageShape.ts", "fileUrlPath": "packages/tlschema/src/shapes/TLImageShape.ts",
@ -3061,7 +3088,16 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": "<string, {\n validate: (prop: any) => any;\n }>;\n meta?: " "text": "<string, "
},
{
"kind": "Reference",
"text": "AnyValidator",
"canonicalReference": "@tldraw/tlschema!~AnyValidator:type"
},
{
"kind": "Content",
"text": ">;\n meta?: "
}, },
{ {
"kind": "Reference", "kind": "Reference",
@ -3070,7 +3106,16 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": "<string, {\n validate: (prop: any) => any;\n }>;\n}" "text": "<string, "
},
{
"kind": "Reference",
"text": "AnyValidator",
"canonicalReference": "@tldraw/tlschema!~AnyValidator:type"
},
{
"kind": "Content",
"text": ">;\n}"
}, },
{ {
"kind": "Content", "kind": "Content",
@ -3082,7 +3127,7 @@
"name": "SchemaShapeInfo", "name": "SchemaShapeInfo",
"typeTokenRange": { "typeTokenRange": {
"startIndex": 1, "startIndex": 1,
"endIndex": 8 "endIndex": 12
} }
}, },
{ {
@ -3548,6 +3593,70 @@
"isOptional": false, "isOptional": false,
"isAbstract": false, "isAbstract": false,
"name": "validate" "name": "validate"
},
{
"kind": "Method",
"canonicalReference": "@tldraw/tlschema!StyleProp#validateUsingKnownGoodVersion:member(1)",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "validateUsingKnownGoodVersion(prevValue: "
},
{
"kind": "Content",
"text": "Type"
},
{
"kind": "Content",
"text": ", newValue: "
},
{
"kind": "Content",
"text": "unknown"
},
{
"kind": "Content",
"text": "): "
},
{
"kind": "Content",
"text": "Type"
},
{
"kind": "Content",
"text": ";"
}
],
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 5,
"endIndex": 6
},
"releaseTag": "Public",
"isProtected": false,
"overloadIndex": 1,
"parameters": [
{
"parameterName": "prevValue",
"parameterTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
},
"isOptional": false
},
{
"parameterName": "newValue",
"parameterTypeTokenRange": {
"startIndex": 3,
"endIndex": 4
},
"isOptional": false
}
],
"isOptional": false,
"isAbstract": false,
"name": "validateUsingKnownGoodVersion"
} }
], ],
"implementsTokenRanges": [ "implementsTokenRanges": [
@ -3557,6 +3666,67 @@
} }
] ]
}, },
{
"kind": "TypeAlias",
"canonicalReference": "@tldraw/tlschema!StylePropValue:type",
"docComment": "/**\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "export type StylePropValue<T extends "
},
{
"kind": "Reference",
"text": "StyleProp",
"canonicalReference": "@tldraw/tlschema!StyleProp:class"
},
{
"kind": "Content",
"text": "<any>"
},
{
"kind": "Content",
"text": "> = "
},
{
"kind": "Content",
"text": "T extends "
},
{
"kind": "Reference",
"text": "StyleProp",
"canonicalReference": "@tldraw/tlschema!StyleProp:class"
},
{
"kind": "Content",
"text": "<infer U> ? U : never"
},
{
"kind": "Content",
"text": ";"
}
],
"fileUrlPath": "packages/tlschema/src/styles/StyleProp.ts",
"releaseTag": "Public",
"name": "StylePropValue",
"typeParameters": [
{
"typeParameterName": "T",
"constraintTokenRange": {
"startIndex": 1,
"endIndex": 3
},
"defaultTypeTokenRange": {
"startIndex": 0,
"endIndex": 0
}
}
],
"typeTokenRange": {
"startIndex": 4,
"endIndex": 7
}
},
{ {
"kind": "Variable", "kind": "Variable",
"canonicalReference": "@tldraw/tlschema!textShapeProps:var", "canonicalReference": "@tldraw/tlschema!textShapeProps:var",

View file

@ -27,14 +27,14 @@ export const assetIdValidator = idValidator<TLAssetId>('asset')
export function createAssetValidator<Type extends string, Props extends JsonObject>( export function createAssetValidator<Type extends string, Props extends JsonObject>(
type: Type, type: Type,
props: T.Validator<Props> props: T.Validator<Props>
): T.ObjectValidator<{ ) {
id: TLAssetId return T.object<{
typeName: 'asset' id: TLAssetId
type: Type typeName: 'asset'
props: Props type: Type
meta: JsonObject props: Props
}> { meta: JsonObject
return T.object({ }>({
id: assetIdValidator, id: assetIdValidator,
typeName: T.literal('asset'), typeName: T.literal('asset'),
type: T.literal(type), type: T.literal(type),

View file

@ -14,11 +14,16 @@ import { createShapeRecordType, getShapePropKeysByStyle } from './records/TLShap
import { storeMigrations } from './store-migrations' import { storeMigrations } from './store-migrations'
import { StyleProp } from './styles/StyleProp' import { StyleProp } from './styles/StyleProp'
type AnyValidator = {
validate: (prop: any) => any
validateUsingKnownGoodVersion?: (prevVersion: any, newVersion: any) => any
}
/** @public */ /** @public */
export type SchemaShapeInfo = { export type SchemaShapeInfo = {
migrations?: Migrations migrations?: Migrations
props?: Record<string, { validate: (prop: any) => any }> props?: Record<string, AnyValidator>
meta?: Record<string, { validate: (prop: any) => any }> meta?: Record<string, AnyValidator>
} }
/** @public */ /** @public */

View file

@ -136,7 +136,7 @@ export {
type TLTextShapeProps, type TLTextShapeProps,
} from './shapes/TLTextShape' } from './shapes/TLTextShape'
export { videoShapeMigrations, videoShapeProps, type TLVideoShape } from './shapes/TLVideoShape' export { videoShapeMigrations, videoShapeProps, type TLVideoShape } from './shapes/TLVideoShape'
export { EnumStyleProp, StyleProp } from './styles/StyleProp' export { EnumStyleProp, StyleProp, type StylePropValue } from './styles/StyleProp'
export { export {
DefaultColorStyle, DefaultColorStyle,
DefaultColorThemePalette, DefaultColorThemePalette,

View file

@ -52,8 +52,8 @@ export function createShapeValidator<
type: T.literal(type), type: T.literal(type),
isLocked: T.boolean, isLocked: T.boolean,
opacity: opacityValidator, opacity: opacityValidator,
props: props ? T.object(props) : (T.jsonValue as T.ObjectValidator<Props>), props: props ? T.object(props) : (T.jsonValue as any),
meta: meta ? T.object(meta) : (T.jsonValue as T.ObjectValidator<Meta>), meta: meta ? T.object(meta) : (T.jsonValue as any),
}) })
} }

View file

@ -90,6 +90,14 @@ export class StyleProp<Type> implements T.Validatable<Type> {
validate(value: unknown) { validate(value: unknown) {
return this.type.validate(value) return this.type.validate(value)
} }
validateUsingKnownGoodVersion(prevValue: Type, newValue: unknown) {
if (this.type.validateUsingKnownGoodVersion) {
return this.type.validateUsingKnownGoodVersion(prevValue, newValue)
} else {
return this.validate(newValue)
}
}
} }
/** /**
@ -107,3 +115,8 @@ export class EnumStyleProp<T> extends StyleProp<T> {
super(id, defaultValue, T.literalEnum(...values)) super(id, defaultValue, T.literalEnum(...values))
} }
} }
/**
* @public
*/
export type StylePropValue<T extends StyleProp<any>> = T extends StyleProp<infer U> ? U : never

View file

@ -86,7 +86,11 @@ const number: Validator<number>;
// @public // @public
function object<Shape extends object>(config: { function object<Shape extends object>(config: {
readonly [K in keyof Shape]: Validatable<Shape[K]>; readonly [K in keyof Shape]: Validatable<Shape[K]>;
}): ObjectValidator<Shape>; }): ObjectValidator<{
[P in ExtractRequiredKeys<Shape>]: Shape[P];
} & {
[P in ExtractOptionalKeys<Shape>]?: Shape[P];
}>;
// @public (undocumented) // @public (undocumented)
export class ObjectValidator<Shape extends object> extends Validator<Shape> { export class ObjectValidator<Shape extends object> extends Validator<Shape> {
@ -136,6 +140,7 @@ declare namespace T {
nullable, nullable,
literalEnum, literalEnum,
ValidatorFn, ValidatorFn,
ValidatorUsingKnownGoodVersionFn,
Validatable, Validatable,
ValidationError, ValidationError,
TypeOf, TypeOf,
@ -166,7 +171,7 @@ declare namespace T {
export { T } export { T }
// @public (undocumented) // @public (undocumented)
type TypeOf<V extends Validatable<unknown>> = V extends Validatable<infer T> ? T : never; type TypeOf<V extends Validatable<any>> = V extends Validatable<infer T> ? T : never;
// @public // @public
function union<Key extends string, Config extends UnionValidatorConfig<Key, Config>>(key: Key, config: Config): UnionValidator<Key, Config>; function union<Key extends string, Config extends UnionValidatorConfig<Key, Config>>(key: Key, config: Config): UnionValidator<Key, Config>;
@ -187,6 +192,7 @@ const unknownObject: Validator<Record<string, unknown>>;
// @public (undocumented) // @public (undocumented)
type Validatable<T> = { type Validatable<T> = {
validate: (value: unknown) => T; validate: (value: unknown) => T;
validateUsingKnownGoodVersion?: (knownGoodValue: T, newValue: unknown) => T;
}; };
// @public (undocumented) // @public (undocumented)
@ -202,7 +208,7 @@ class ValidationError extends Error {
// @public (undocumented) // @public (undocumented)
export class Validator<T> implements Validatable<T> { export class Validator<T> implements Validatable<T> {
constructor(validationFn: ValidatorFn<T>); constructor(validationFn: ValidatorFn<T>, validateUsingKnownGoodVersionFn?: undefined | ValidatorUsingKnownGoodVersionFn<T, T>);
check(name: string, checkFn: (value: T) => void): Validator<T>; check(name: string, checkFn: (value: T) => void): Validator<T>;
// (undocumented) // (undocumented)
check(checkFn: (value: T) => void): Validator<T>; check(checkFn: (value: T) => void): Validator<T>;
@ -212,12 +218,19 @@ export class Validator<T> implements Validatable<T> {
refine<U>(otherValidationFn: (value: T) => U): Validator<U>; refine<U>(otherValidationFn: (value: T) => U): Validator<U>;
validate(value: unknown): T; validate(value: unknown): T;
// (undocumented) // (undocumented)
validateUsingKnownGoodVersion(knownGoodValue: T, newValue: unknown): T;
// (undocumented)
readonly validateUsingKnownGoodVersionFn?: undefined | ValidatorUsingKnownGoodVersionFn<T, T>;
// (undocumented)
readonly validationFn: ValidatorFn<T>; readonly validationFn: ValidatorFn<T>;
} }
// @public (undocumented) // @public (undocumented)
type ValidatorFn<T> = (value: unknown) => T; type ValidatorFn<T> = (value: unknown) => T;
// @public (undocumented)
type ValidatorUsingKnownGoodVersionFn<In, Out = In> = (knownGoodValue: In, value: unknown) => Out;
// (No @packageDocumentation comment for this package) // (No @packageDocumentation comment for this package)
``` ```

View file

@ -2142,7 +2142,25 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": "<Shape>" "text": "<{\n [P in "
},
{
"kind": "Reference",
"text": "ExtractRequiredKeys",
"canonicalReference": "@tldraw/validate!~ExtractRequiredKeys:type"
},
{
"kind": "Content",
"text": "<Shape>]: Shape[P];\n} & {\n [P in "
},
{
"kind": "Reference",
"text": "ExtractOptionalKeys",
"canonicalReference": "@tldraw/validate!~ExtractOptionalKeys:type"
},
{
"kind": "Content",
"text": "<Shape>]?: Shape[P];\n}>"
}, },
{ {
"kind": "Content", "kind": "Content",
@ -2152,7 +2170,7 @@
"fileUrlPath": "packages/validate/src/lib/validation.ts", "fileUrlPath": "packages/validate/src/lib/validation.ts",
"returnTypeTokenRange": { "returnTypeTokenRange": {
"startIndex": 7, "startIndex": 7,
"endIndex": 9 "endIndex": 13
}, },
"releaseTag": "Public", "releaseTag": "Public",
"overloadIndex": 1, "overloadIndex": 1,
@ -2722,7 +2740,7 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": "<unknown>" "text": "<any>"
}, },
{ {
"kind": "Content", "kind": "Content",
@ -3193,7 +3211,7 @@
}, },
{ {
"kind": "Content", "kind": "Content",
"text": "{\n validate: (value: unknown) => T;\n}" "text": "{\n validate: (value: unknown) => T;\n validateUsingKnownGoodVersion?: (knownGoodValue: T, newValue: unknown) => T;\n}"
}, },
{ {
"kind": "Content", "kind": "Content",
@ -3461,6 +3479,23 @@
"kind": "Content", "kind": "Content",
"text": "<T>" "text": "<T>"
}, },
{
"kind": "Content",
"text": ", validateUsingKnownGoodVersionFn?: "
},
{
"kind": "Content",
"text": "undefined | "
},
{
"kind": "Reference",
"text": "ValidatorUsingKnownGoodVersionFn",
"canonicalReference": "@tldraw/validate!T.ValidatorUsingKnownGoodVersionFn:type"
},
{
"kind": "Content",
"text": "<T, T>"
},
{ {
"kind": "Content", "kind": "Content",
"text": ");" "text": ");"
@ -3477,6 +3512,14 @@
"endIndex": 3 "endIndex": 3
}, },
"isOptional": false "isOptional": false
},
{
"parameterName": "validateUsingKnownGoodVersionFn",
"parameterTypeTokenRange": {
"startIndex": 4,
"endIndex": 7
},
"isOptional": true
} }
] ]
}, },
@ -3841,6 +3884,109 @@
"isAbstract": false, "isAbstract": false,
"name": "validate" "name": "validate"
}, },
{
"kind": "Method",
"canonicalReference": "@tldraw/validate!T.Validator#validateUsingKnownGoodVersion:member(1)",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "validateUsingKnownGoodVersion(knownGoodValue: "
},
{
"kind": "Content",
"text": "T"
},
{
"kind": "Content",
"text": ", newValue: "
},
{
"kind": "Content",
"text": "unknown"
},
{
"kind": "Content",
"text": "): "
},
{
"kind": "Content",
"text": "T"
},
{
"kind": "Content",
"text": ";"
}
],
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 5,
"endIndex": 6
},
"releaseTag": "Public",
"isProtected": false,
"overloadIndex": 1,
"parameters": [
{
"parameterName": "knownGoodValue",
"parameterTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
},
"isOptional": false
},
{
"parameterName": "newValue",
"parameterTypeTokenRange": {
"startIndex": 3,
"endIndex": 4
},
"isOptional": false
}
],
"isOptional": false,
"isAbstract": false,
"name": "validateUsingKnownGoodVersion"
},
{
"kind": "Property",
"canonicalReference": "@tldraw/validate!T.Validator#validateUsingKnownGoodVersionFn:member",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "readonly validateUsingKnownGoodVersionFn?: "
},
{
"kind": "Content",
"text": "undefined | "
},
{
"kind": "Reference",
"text": "ValidatorUsingKnownGoodVersionFn",
"canonicalReference": "@tldraw/validate!T.ValidatorUsingKnownGoodVersionFn:type"
},
{
"kind": "Content",
"text": "<T, T>"
},
{
"kind": "Content",
"text": ";"
}
],
"isReadonly": true,
"isOptional": true,
"releaseTag": "Public",
"name": "validateUsingKnownGoodVersionFn",
"propertyTypeTokenRange": {
"startIndex": 1,
"endIndex": 4
},
"isStatic": false,
"isProtected": false,
"isAbstract": false
},
{ {
"kind": "Property", "kind": "Property",
"canonicalReference": "@tldraw/validate!T.Validator#validationFn:member", "canonicalReference": "@tldraw/validate!T.Validator#validationFn:member",
@ -3922,6 +4068,64 @@
"startIndex": 1, "startIndex": 1,
"endIndex": 2 "endIndex": 2
} }
},
{
"kind": "TypeAlias",
"canonicalReference": "@tldraw/validate!T.ValidatorUsingKnownGoodVersionFn:type",
"docComment": "/**\n * @public\n */\n",
"excerptTokens": [
{
"kind": "Content",
"text": "export type ValidatorUsingKnownGoodVersionFn<In, Out = "
},
{
"kind": "Content",
"text": "In"
},
{
"kind": "Content",
"text": "> = "
},
{
"kind": "Content",
"text": "(knownGoodValue: In, value: unknown) => Out"
},
{
"kind": "Content",
"text": ";"
}
],
"fileUrlPath": "packages/validate/src/lib/validation.ts",
"releaseTag": "Public",
"name": "ValidatorUsingKnownGoodVersionFn",
"typeParameters": [
{
"typeParameterName": "In",
"constraintTokenRange": {
"startIndex": 0,
"endIndex": 0
},
"defaultTypeTokenRange": {
"startIndex": 0,
"endIndex": 0
}
},
{
"typeParameterName": "Out",
"constraintTokenRange": {
"startIndex": 0,
"endIndex": 0
},
"defaultTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
}
}
],
"typeTokenRange": {
"startIndex": 3,
"endIndex": 4
}
} }
] ]
}, },
@ -4224,6 +4428,23 @@
"kind": "Content", "kind": "Content",
"text": "<T>" "text": "<T>"
}, },
{
"kind": "Content",
"text": ", validateUsingKnownGoodVersionFn?: "
},
{
"kind": "Content",
"text": "undefined | "
},
{
"kind": "Reference",
"text": "ValidatorUsingKnownGoodVersionFn",
"canonicalReference": "@tldraw/validate!T.ValidatorUsingKnownGoodVersionFn:type"
},
{
"kind": "Content",
"text": "<T, T>"
},
{ {
"kind": "Content", "kind": "Content",
"text": ");" "text": ");"
@ -4240,6 +4461,14 @@
"endIndex": 3 "endIndex": 3
}, },
"isOptional": false "isOptional": false
},
{
"parameterName": "validateUsingKnownGoodVersionFn",
"parameterTypeTokenRange": {
"startIndex": 4,
"endIndex": 7
},
"isOptional": true
} }
] ]
}, },
@ -4604,6 +4833,109 @@
"isAbstract": false, "isAbstract": false,
"name": "validate" "name": "validate"
}, },
{
"kind": "Method",
"canonicalReference": "@tldraw/validate!Validator#validateUsingKnownGoodVersion:member(1)",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "validateUsingKnownGoodVersion(knownGoodValue: "
},
{
"kind": "Content",
"text": "T"
},
{
"kind": "Content",
"text": ", newValue: "
},
{
"kind": "Content",
"text": "unknown"
},
{
"kind": "Content",
"text": "): "
},
{
"kind": "Content",
"text": "T"
},
{
"kind": "Content",
"text": ";"
}
],
"isStatic": false,
"returnTypeTokenRange": {
"startIndex": 5,
"endIndex": 6
},
"releaseTag": "Public",
"isProtected": false,
"overloadIndex": 1,
"parameters": [
{
"parameterName": "knownGoodValue",
"parameterTypeTokenRange": {
"startIndex": 1,
"endIndex": 2
},
"isOptional": false
},
{
"parameterName": "newValue",
"parameterTypeTokenRange": {
"startIndex": 3,
"endIndex": 4
},
"isOptional": false
}
],
"isOptional": false,
"isAbstract": false,
"name": "validateUsingKnownGoodVersion"
},
{
"kind": "Property",
"canonicalReference": "@tldraw/validate!Validator#validateUsingKnownGoodVersionFn:member",
"docComment": "",
"excerptTokens": [
{
"kind": "Content",
"text": "readonly validateUsingKnownGoodVersionFn?: "
},
{
"kind": "Content",
"text": "undefined | "
},
{
"kind": "Reference",
"text": "ValidatorUsingKnownGoodVersionFn",
"canonicalReference": "@tldraw/validate!T.ValidatorUsingKnownGoodVersionFn:type"
},
{
"kind": "Content",
"text": "<T, T>"
},
{
"kind": "Content",
"text": ";"
}
],
"isReadonly": true,
"isOptional": true,
"releaseTag": "Public",
"name": "validateUsingKnownGoodVersionFn",
"propertyTypeTokenRange": {
"startIndex": 1,
"endIndex": 4
},
"isStatic": false,
"isProtected": false,
"isAbstract": false
},
{ {
"kind": "Property", "kind": "Property",
"canonicalReference": "@tldraw/validate!Validator#validationFn:member", "canonicalReference": "@tldraw/validate!Validator#validationFn:member",

View file

@ -53,6 +53,7 @@
} }
}, },
"devDependencies": { "devDependencies": {
"lazyrepo": "0.0.0-alpha.27" "lazyrepo": "0.0.0-alpha.27",
"lodash.isequal": "^4.5.0"
} }
} }

View file

@ -9,9 +9,26 @@ import {
/** @public */ /** @public */
export type ValidatorFn<T> = (value: unknown) => T export type ValidatorFn<T> = (value: unknown) => T
/** @public */
export type ValidatorUsingKnownGoodVersionFn<In, Out = In> = (
knownGoodValue: In,
value: unknown
) => Out
/** @public */ /** @public */
export type Validatable<T> = { validate: (value: unknown) => T } export type Validatable<T> = {
validate: (value: unknown) => T
/**
* This is a performance optimizing version of validate that can use a previous
* version of the value to avoid revalidating every part of the new value if
* any part of it has not changed since the last validation.
*
* If the value has not changed but is not referentially equal, the function
* should return the previous value.
* @returns
*/
validateUsingKnownGoodVersion?: (knownGoodValue: T, newValue: unknown) => T
}
function formatPath(path: ReadonlyArray<number | string>): string | null { function formatPath(path: ReadonlyArray<number | string>): string | null {
if (!path.length) { if (!path.length) {
@ -92,11 +109,14 @@ function typeToString(value: unknown): string {
} }
/** @public */ /** @public */
export type TypeOf<V extends Validatable<unknown>> = V extends Validatable<infer T> ? T : never export type TypeOf<V extends Validatable<any>> = V extends Validatable<infer T> ? T : never
/** @public */ /** @public */
export class Validator<T> implements Validatable<T> { export class Validator<T> implements Validatable<T> {
constructor(readonly validationFn: ValidatorFn<T>) {} constructor(
readonly validationFn: ValidatorFn<T>,
readonly validateUsingKnownGoodVersionFn?: ValidatorUsingKnownGoodVersionFn<T>
) {}
/** /**
* Asserts that the passed value is of the correct type and returns it. The returned value is * Asserts that the passed value is of the correct type and returns it. The returned value is
@ -110,6 +130,18 @@ export class Validator<T> implements Validatable<T> {
return validated return validated
} }
validateUsingKnownGoodVersion(knownGoodValue: T, newValue: unknown): T {
if (Object.is(knownGoodValue, newValue)) {
return knownGoodValue as T
}
if (this.validateUsingKnownGoodVersionFn) {
return this.validateUsingKnownGoodVersionFn(knownGoodValue, newValue)
}
return this.validate(newValue)
}
/** Checks that the passed value is of the correct type. */ /** Checks that the passed value is of the correct type. */
isValid(value: unknown): value is T { isValid(value: unknown): value is T {
try { try {
@ -141,9 +173,19 @@ export class Validator<T> implements Validatable<T> {
* if the value can't be converted to the new type, or return the new type otherwise. * if the value can't be converted to the new type, or return the new type otherwise.
*/ */
refine<U>(otherValidationFn: (value: T) => U): Validator<U> { refine<U>(otherValidationFn: (value: T) => U): Validator<U> {
return new Validator((value) => { return new Validator(
return otherValidationFn(this.validate(value)) (value) => {
}) return otherValidationFn(this.validate(value))
},
(knownGoodValue, newValue) => {
const validated = this.validateUsingKnownGoodVersion(knownGoodValue as any, newValue)
if (Object.is(knownGoodValue, validated)) {
return knownGoodValue
}
return otherValidationFn(validated)
}
)
} }
/** /**
@ -179,13 +221,40 @@ export class Validator<T> implements Validatable<T> {
/** @public */ /** @public */
export class ArrayOfValidator<T> extends Validator<T[]> { export class ArrayOfValidator<T> extends Validator<T[]> {
constructor(readonly itemValidator: Validatable<T>) { constructor(readonly itemValidator: Validatable<T>) {
super((value) => { super(
const arr = array.validate(value) (value) => {
for (let i = 0; i < arr.length; i++) { const arr = array.validate(value)
prefixError(i, () => itemValidator.validate(arr[i])) for (let i = 0; i < arr.length; i++) {
prefixError(i, () => itemValidator.validate(arr[i]))
}
return arr as T[]
},
(knownGoodValue, newValue) => {
if (!itemValidator.validateUsingKnownGoodVersion) return this.validate(newValue)
const arr = array.validate(newValue)
let isDifferent = knownGoodValue.length !== arr.length
for (let i = 0; i < arr.length; i++) {
const item = arr[i]
if (i >= knownGoodValue.length) {
isDifferent = true
prefixError(i, () => itemValidator.validate(item))
continue
}
// sneaky quick check here to avoid the prefix + validator overhead
if (Object.is(knownGoodValue[i], item)) {
continue
}
const checkedItem = prefixError(i, () =>
itemValidator.validateUsingKnownGoodVersion!(knownGoodValue[i], item)
)
if (!Object.is(checkedItem, knownGoodValue[i])) {
isDifferent = true
}
}
return isDifferent ? (newValue as T[]) : knownGoodValue
} }
return arr as T[] )
})
} }
nonEmpty() { nonEmpty() {
@ -213,27 +282,68 @@ export class ObjectValidator<Shape extends object> extends Validator<Shape> {
}, },
private readonly shouldAllowUnknownProperties = false private readonly shouldAllowUnknownProperties = false
) { ) {
super((object) => { super(
if (typeof object !== 'object' || object === null) { (object) => {
throw new ValidationError(`Expected object, got ${typeToString(object)}`) if (typeof object !== 'object' || object === null) {
} throw new ValidationError(`Expected object, got ${typeToString(object)}`)
}
for (const [key, validator] of Object.entries(config)) { for (const [key, validator] of Object.entries(config)) {
prefixError(key, () => { prefixError(key, () => {
;(validator as Validator<unknown>).validate(getOwnProperty(object, key)) ;(validator as Validator<unknown>).validate(getOwnProperty(object, key))
}) })
} }
if (!shouldAllowUnknownProperties) { if (!shouldAllowUnknownProperties) {
for (const key of Object.keys(object)) { for (const key of Object.keys(object)) {
if (!hasOwnProperty(config, key)) { if (!hasOwnProperty(config, key)) {
throw new ValidationError(`Unexpected property`, [key]) throw new ValidationError(`Unexpected property`, [key])
}
} }
} }
}
return object as Shape return object as Shape
}) },
(knownGoodValue, newValue) => {
if (typeof newValue !== 'object' || newValue === null) {
throw new ValidationError(`Expected object, got ${typeToString(newValue)}`)
}
let isDifferent = false
for (const [key, validator] of Object.entries(config)) {
const prev = getOwnProperty(knownGoodValue, key)
const next = getOwnProperty(newValue, key)
// sneaky quick check here to avoid the prefix + validator overhead
if (Object.is(prev, next)) {
continue
}
const checked = prefixError(key, () => {
return (validator as Validator<unknown>).validateUsingKnownGoodVersion(prev, next)
})
if (!Object.is(checked, prev)) {
isDifferent = true
}
}
if (!shouldAllowUnknownProperties) {
for (const key of Object.keys(newValue)) {
if (!hasOwnProperty(config, key)) {
throw new ValidationError(`Unexpected property`, [key])
}
}
}
for (const key of Object.keys(knownGoodValue)) {
if (!hasOwnProperty(newValue, key)) {
isDifferent = true
break
}
}
return isDifferent ? (newValue as Shape) : knownGoodValue
}
)
} }
allowUnknownProperties() { allowUnknownProperties() {
@ -257,7 +367,7 @@ export class ObjectValidator<Shape extends object> extends Validator<Shape> {
extend<Extension extends Record<string, unknown>>(extension: { extend<Extension extends Record<string, unknown>>(extension: {
readonly [K in keyof Extension]: Validatable<Extension[K]> readonly [K in keyof Extension]: Validatable<Extension[K]>
}): ObjectValidator<Shape & Extension> { }): ObjectValidator<Shape & Extension> {
return new ObjectValidator({ ...this.config, ...extension }) as ObjectValidator< return new ObjectValidator({ ...this.config, ...extension }) as any as ObjectValidator<
Shape & Extension Shape & Extension
> >
} }
@ -280,25 +390,61 @@ export class UnionValidator<
private readonly config: Config, private readonly config: Config,
private readonly unknownValueValidation: (value: object, variant: string) => UnknownValue private readonly unknownValueValidation: (value: object, variant: string) => UnknownValue
) { ) {
super((input) => { super(
if (typeof input !== 'object' || input === null) { (input) => {
throw new ValidationError(`Expected an object, got ${typeToString(input)}`, []) this.expectObject(input)
}
const variant = getOwnProperty(input, key) as keyof Config | undefined const { matchingSchema, variant } = this.getMatchingSchemaAndVariant(input)
if (typeof variant !== 'string') { if (matchingSchema === undefined) {
throw new ValidationError( return this.unknownValueValidation(input, variant)
`Expected a string for key "${key}", got ${typeToString(variant)}` }
)
}
const matchingSchema = hasOwnProperty(config, variant) ? config[variant] : undefined return prefixError(`(${key} = ${variant})`, () => matchingSchema.validate(input))
if (matchingSchema === undefined) { },
return this.unknownValueValidation(input, variant) (prevValue, newValue) => {
} this.expectObject(newValue)
this.expectObject(prevValue)
return prefixError(`(${key} = ${variant})`, () => matchingSchema.validate(input)) const { matchingSchema, variant } = this.getMatchingSchemaAndVariant(newValue)
}) if (matchingSchema === undefined) {
return this.unknownValueValidation(newValue, variant)
}
if (getOwnProperty(prevValue, key) !== getOwnProperty(newValue, key)) {
// the type has changed so bail out and do a regular validation
return prefixError(`(${key} = ${variant})`, () => matchingSchema.validate(newValue))
}
return prefixError(`(${key} = ${variant})`, () => {
if (matchingSchema.validateUsingKnownGoodVersion) {
return matchingSchema.validateUsingKnownGoodVersion(prevValue, newValue)
} else {
return matchingSchema.validate(newValue)
}
})
}
)
}
private expectObject(value: unknown): asserts value is object {
if (typeof value !== 'object' || value === null) {
throw new ValidationError(`Expected an object, got ${typeToString(value)}`, [])
}
}
private getMatchingSchemaAndVariant(object: object): {
matchingSchema: Validatable<any> | undefined
variant: string
} {
const variant = getOwnProperty(object, this.key) as keyof Config | undefined
if (typeof variant !== 'string') {
throw new ValidationError(
`Expected a string for key "${this.key}", got ${typeToString(variant)}`
)
}
const matchingSchema = hasOwnProperty(this.config, variant) ? this.config[variant] : undefined
return { matchingSchema, variant }
} }
validateUnknownVariants<Unknown>( validateUnknownVariants<Unknown>(
@ -314,20 +460,65 @@ export class DictValidator<Key extends string, Value> extends Validator<Record<K
public readonly keyValidator: Validatable<Key>, public readonly keyValidator: Validatable<Key>,
public readonly valueValidator: Validatable<Value> public readonly valueValidator: Validatable<Value>
) { ) {
super((object) => { super(
if (typeof object !== 'object' || object === null) { (object) => {
throw new ValidationError(`Expected object, got ${typeToString(object)}`) if (typeof object !== 'object' || object === null) {
} throw new ValidationError(`Expected object, got ${typeToString(object)}`)
}
for (const [key, value] of Object.entries(object)) { for (const [key, value] of Object.entries(object)) {
prefixError(key, () => { prefixError(key, () => {
keyValidator.validate(key) keyValidator.validate(key)
valueValidator.validate(value) valueValidator.validate(value)
}) })
} }
return object as Record<Key, Value> return object as Record<Key, Value>
}) },
(knownGoodValue, newValue) => {
if (typeof newValue !== 'object' || newValue === null) {
throw new ValidationError(`Expected object, got ${typeToString(newValue)}`)
}
let isDifferent = false
for (const [key, value] of Object.entries(newValue)) {
if (!hasOwnProperty(knownGoodValue, key)) {
isDifferent = true
prefixError(key, () => {
keyValidator.validate(key)
valueValidator.validate(value)
})
continue
}
const prev = getOwnProperty(knownGoodValue, key)
const next = value
// sneaky quick check here to avoid the prefix + validator overhead
if (Object.is(prev, next)) {
continue
}
const checked = prefixError(key, () => {
if (valueValidator.validateUsingKnownGoodVersion) {
return valueValidator.validateUsingKnownGoodVersion(prev as any, next)
} else {
return valueValidator.validate(next)
}
})
if (!Object.is(checked, prev)) {
isDifferent = true
}
}
for (const key of Object.keys(knownGoodValue)) {
if (!hasOwnProperty(newValue, key)) {
isDifferent = true
break
}
}
return isDifferent ? (newValue as Record<Key, Value>) : knownGoodValue
}
)
} }
} }
@ -477,6 +668,14 @@ export const unknownObject = new Validator<Record<string, unknown>>((value) => {
return value as Record<string, unknown> return value as Record<string, unknown>
}) })
type ExtractRequiredKeys<T extends object> = {
[K in keyof T]: undefined extends T[K] ? never : K
}[keyof T]
type ExtractOptionalKeys<T extends object> = {
[K in keyof T]: undefined extends T[K] ? K : never
}[keyof T]
/** /**
* Validate an object has a particular shape. * Validate an object has a particular shape.
* *
@ -484,8 +683,18 @@ export const unknownObject = new Validator<Record<string, unknown>>((value) => {
*/ */
export function object<Shape extends object>(config: { export function object<Shape extends object>(config: {
readonly [K in keyof Shape]: Validatable<Shape[K]> readonly [K in keyof Shape]: Validatable<Shape[K]>
}): ObjectValidator<Shape> { }): ObjectValidator<
return new ObjectValidator(config) { [P in ExtractRequiredKeys<Shape>]: Shape[P] } & { [P in ExtractOptionalKeys<Shape>]?: Shape[P] }
> {
return new ObjectValidator(config) as any
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return (
typeof value === 'object' &&
value !== null &&
(value.constructor === Object || !value.constructor)
)
} }
function isValidJson(value: any): value is JsonValue { function isValidJson(value: any): value is JsonValue {
@ -502,7 +711,7 @@ function isValidJson(value: any): value is JsonValue {
return value.every(isValidJson) return value.every(isValidJson)
} }
if (typeof value === 'object') { if (isPlainObject(value)) {
return Object.values(value).every(isValidJson) return Object.values(value).every(isValidJson)
} }
@ -514,13 +723,64 @@ function isValidJson(value: any): value is JsonValue {
* *
* @public * @public
*/ */
export const jsonValue = new Validator<JsonValue>((value): JsonValue => { export const jsonValue: Validator<JsonValue> = new Validator<JsonValue>(
if (isValidJson(value)) { (value): JsonValue => {
return value as JsonValue if (isValidJson(value)) {
} return value as JsonValue
}
throw new ValidationError(`Expected json serializable value, got ${typeof value}`) throw new ValidationError(`Expected json serializable value, got ${typeof value}`)
}) },
(knownGoodValue, newValue) => {
if (Array.isArray(knownGoodValue) && Array.isArray(newValue)) {
let isDifferent = knownGoodValue.length !== newValue.length
for (let i = 0; i < newValue.length; i++) {
if (i >= knownGoodValue.length) {
isDifferent = true
jsonValue.validate(newValue[i])
continue
}
const prev = knownGoodValue[i]
const next = newValue[i]
if (Object.is(prev, next)) {
continue
}
const checked = jsonValue.validateUsingKnownGoodVersion!(prev, next)
if (!Object.is(checked, prev)) {
isDifferent = true
}
}
return isDifferent ? (newValue as JsonValue) : knownGoodValue
} else if (isPlainObject(knownGoodValue) && isPlainObject(newValue)) {
let isDifferent = false
for (const key of Object.keys(newValue)) {
if (!hasOwnProperty(knownGoodValue, key)) {
isDifferent = true
jsonValue.validate(newValue[key])
continue
}
const prev = knownGoodValue[key]
const next = newValue[key]
if (Object.is(prev, next)) {
continue
}
const checked = jsonValue.validateUsingKnownGoodVersion!(prev!, next)
if (!Object.is(checked, prev)) {
isDifferent = true
}
}
for (const key of Object.keys(knownGoodValue)) {
if (!hasOwnProperty(newValue, key)) {
isDifferent = true
break
}
}
return isDifferent ? (newValue as JsonValue) : knownGoodValue
} else {
return jsonValue.validate(newValue)
}
}
)
/** /**
* Validate an object has a particular shape. * Validate an object has a particular shape.
@ -581,14 +841,20 @@ export function model<T extends { readonly id: string }>(
name: string, name: string,
validator: Validatable<T> validator: Validatable<T>
): Validator<T> { ): Validator<T> {
return new Validator((value) => { return new Validator(
const prefix = (value) => {
value && typeof value === 'object' && 'id' in value && typeof value.id === 'string' return prefixError(name, () => validator.validate(value))
? `${name}(id = ${value.id})` },
: name (prevValue, newValue) => {
return prefixError(name, () => {
return prefixError(prefix, () => validator.validate(value)) if (validator.validateUsingKnownGoodVersion) {
}) return validator.validateUsingKnownGoodVersion(prevValue, newValue)
} else {
return validator.validate(newValue)
}
})
}
)
} }
/** @public */ /** @public */
@ -604,18 +870,37 @@ export function setEnum<T>(values: ReadonlySet<T>): Validator<T> {
/** @public */ /** @public */
export function optional<T>(validator: Validatable<T>): Validator<T | undefined> { export function optional<T>(validator: Validatable<T>): Validator<T | undefined> {
return new Validator((value) => { return new Validator(
if (value === undefined) return undefined (value) => {
return validator.validate(value) if (value === undefined) return undefined
}) return validator.validate(value)
},
(knownGoodValue, newValue) => {
if (knownGoodValue === undefined && newValue === undefined) return undefined
if (newValue === undefined) return undefined
if (validator.validateUsingKnownGoodVersion && knownGoodValue !== undefined) {
return validator.validateUsingKnownGoodVersion(knownGoodValue as T, newValue)
}
return validator.validate(newValue)
}
)
} }
/** @public */ /** @public */
export function nullable<T>(validator: Validatable<T>): Validator<T | null> { export function nullable<T>(validator: Validatable<T>): Validator<T | null> {
return new Validator((value) => { return new Validator(
if (value === null) return null (value) => {
return validator.validate(value) if (value === null) return null
}) return validator.validate(value)
},
(knownGoodValue, newValue) => {
if (newValue === null) return null
if (validator.validateUsingKnownGoodVersion && knownGoodValue !== null) {
return validator.validateUsingKnownGoodVersion(knownGoodValue as T, newValue)
}
return validator.validate(newValue)
}
)
} }
/** @public */ /** @public */

View file

@ -0,0 +1,379 @@
import { mapObjectMapValues } from '@tldraw/utils'
import isEqual from 'lodash.isequal'
import { T, Validator } from '..'
class RandomSource {
private seed: number
constructor(seed: number) {
this.seed = seed
}
nextFloat(): number {
this.seed = (this.seed * 9301 + 49297) % 233280
return this.seed / 233280
}
nextInt(max: number): number {
return Math.floor(this.nextFloat() * max)
}
nextIntInRange(min: number, max: number): number {
return this.nextInt(max - min) + min
}
nextId(): string {
return this.nextInt(Number.MAX_SAFE_INTEGER).toString(36)
}
selectOne<T>(arr: readonly T[]): T {
return arr[this.nextInt(arr.length)]
}
choice(probability: number): boolean {
return this.nextFloat() < probability
}
executeOne<Result>(
_choices: Record<string, (() => Result) | { weight?: number; do(): Result }>
): Result {
const choices = Object.values(_choices).map((choice) => {
if (typeof choice === 'function') {
return { weight: 1, do: choice }
}
return choice
})
const totalWeight = Object.values(choices).reduce(
(total, choice) => total + (choice.weight ?? 1),
0
)
const randomWeight = this.nextInt(totalWeight)
let weight = 0
for (const choice of Object.values(choices)) {
weight += choice.weight ?? 1
if (randomWeight < weight) {
return choice.do()
}
}
throw new Error('unreachable')
}
nextPropertyName(): string {
return this.selectOne(['foo', 'bar', 'baz', 'qux', 'mux', 'bah'])
}
nextJsonValue(): any {
return this.executeOne<any>({
string: { weight: 1, do: () => this.nextId() },
number: { weight: 1, do: () => this.nextFloat() },
integer: { weight: 1, do: () => this.nextInt(100) },
boolean: { weight: 1, do: () => this.choice(0.5) },
null: { weight: 1, do: () => null },
array: {
weight: 1,
do: () => {
const numItems = this.nextInt(4)
const result = []
for (let i = 0; i < numItems; i++) {
result.push(this.nextJsonValue())
}
return result
},
},
object: {
weight: 1,
do: () => {
const numItems = this.nextInt(4)
const result = {} as any
for (let i = 0; i < numItems; i++) {
result[this.nextPropertyName()] = this.nextJsonValue()
}
return result
},
},
})
}
nextTestType(depth: number): TestType {
if (depth >= 3) {
return this.selectOne(Object.values(builtinTypes))
}
return this.executeOne<TestType>({
primitive: () => this.selectOne(Object.values(builtinTypes)),
array: () => generateArrayType(this, depth),
object: () => generateObjectType(this, {}, depth),
union: () => generateUnionType(this, depth),
dict: () => generateDictType(this, depth),
model: () => {
const objType = generateObjectType(this, {}, depth)
const name = this.nextPropertyName()
return {
...objType,
validator: T.model(name, objType.validator),
}
},
})
}
}
type TestType = {
validator: T.Validator<any>
generateValid: (source: RandomSource) => any
generateInvalid: (source: RandomSource) => any
}
const builtinTypes = {
string: {
validator: T.string,
generateValid: (source) => source.selectOne(['a', 'b', 'c', 'd']),
generateInvalid: (source) => source.selectOne([5, /regexp/, {}]),
},
number: {
validator: T.number,
generateValid: (source) => source.nextInt(5),
generateInvalid: (source) => source.selectOne(['a', /num/]),
},
integer: {
validator: T.integer,
generateValid: (source) => source.nextInt(5),
generateInvalid: (source) => source.selectOne([0.2, '3', 5n, /int/]),
},
json: {
validator: T.jsonValue,
generateValid: (source) => source.nextJsonValue(),
generateInvalid: (source) => source.selectOne([/regexp/, 343n, { key: /regexp/ }]),
},
} as const satisfies Record<string, TestType>
function generateObjectType(
source: RandomSource,
injectProperties: Record<string, TestType>,
depth: number
): TestType {
const numProperties = source.nextIntInRange(1, 5)
const propertyTypes: Record<string, TestType> = {
...injectProperties,
}
const optionalTypes = new Set<string>()
const nullableTypes = new Set<string>()
for (let i = 0; i < numProperties; i++) {
const type = source.nextTestType(depth + 1)
const name = source.nextPropertyName()
if (source.choice(0.2)) {
optionalTypes.add(name)
}
if (source.choice(0.2)) {
nullableTypes.add(name)
}
let validator = type.validator
if (nullableTypes.has(name)) {
validator = validator.nullable()
}
if (optionalTypes.has(name)) {
validator = validator.optional()
}
propertyTypes[name] = { ...type, validator }
}
const generateValid = (source: RandomSource) => {
const result = {} as any
for (const [name, type] of Object.entries(propertyTypes)) {
if (optionalTypes.has(name) && source.choice(0.2)) {
continue
} else if (nullableTypes.has(name) && source.choice(0.2)) {
result[name] = null
continue
}
result[name] = type.generateValid(source)
}
return result
}
return {
validator: T.object(mapObjectMapValues(propertyTypes, (_, { validator }) => validator)),
generateValid,
generateInvalid: (source) => {
return source.executeOne<any>({
otherType: () =>
source.executeOne<any>({
string: () => source.selectOne(['a', 'b', 'c', 'd']),
number: () => source.nextInt(5),
array: () => [source.nextId(), source.nextFloat()],
bool: () => true,
}),
missingProperty: () => {
const val = generateValid(source)
const keyToDelete = source.selectOne(
Object.keys(val).filter((key) => !optionalTypes.has(key))
)
if (!keyToDelete) {
// no non-optional properties, do a invalid property test instead
val[keyToDelete] =
propertyTypes[source.selectOne(Object.keys(propertyTypes))].generateInvalid(source)
return val
}
delete val[keyToDelete]
return val
},
extraProperty: () => {
const val = generateValid(source)
val[source.nextPropertyName() + '_'] = source.nextJsonValue()
return val
},
invalidProperty: () => {
const val = generateValid(source)
const keyToChange = source.selectOne(Object.keys(propertyTypes))
val[keyToChange] = propertyTypes[keyToChange].generateInvalid(source)
return val
},
})
},
}
}
function generateDictType(source: RandomSource, depth: number): TestType {
const keyType = builtinTypes.string
const keySet = ['a', 'b', 'c', 'd', 'e', 'f'] as const
const valueType = source.nextTestType(depth + 1)
const validator = T.dict(keyType.validator, valueType.validator)
const generateValid = (source: RandomSource) => {
const result = {} as any
const numItems = source.nextInt(4)
for (let i = 0; i < numItems; i++) {
result[source.selectOne(keySet)] = valueType.generateValid(source)
}
return result
}
return {
validator,
generateValid,
generateInvalid: (source) => {
const result = generateValid(source)
const key = source.selectOne(Object.keys(result)) ?? source.nextPropertyName()
result[key] = valueType.generateInvalid(source)
return result
},
}
}
function createLiteralType(value: string): TestType {
return {
validator: T.literal(value),
generateValid: () => value,
generateInvalid: (source) => source.selectOne(['_invalid_' + value, 2324, {}]),
}
}
function generateUnionType(source: RandomSource, depth: number): TestType {
const key = source.selectOne(['type', 'name', 'kind'])
const numMembers = source.nextIntInRange(1, 4)
const members: TestType[] = []
const unionMap: Record<string, Validator<any>> = {}
for (let i = 0; i < numMembers; i++) {
const id = source.nextId()
const keyType = createLiteralType(id)
const type = generateObjectType(source, { [key]: keyType }, depth + 1)
members.push(type)
unionMap[id] = type.validator
}
const validator = T.union(key, unionMap)
return {
validator,
generateValid: (source) => {
const member = source.selectOne(members)
return member.generateValid(source)
},
generateInvalid(source) {
return source.executeOne<any>({
otherType: () => source.selectOne(['_invalid_', 2324, {}]),
badMember: {
weight: 4,
do() {
const member = source.selectOne(members)
return member.generateInvalid(source)
},
},
})
},
}
}
function generateArrayType(source: RandomSource, depth: number): TestType {
const valueType = source.nextTestType(depth + 1)
const validator = T.arrayOf(valueType.validator)
const generateValid = (source: RandomSource) => {
const result = [] as any[]
const numItems = source.nextInt(4)
for (let i = 0; i < numItems; i++) {
result.push(valueType.generateValid(source))
}
return result
}
return {
validator,
generateValid,
generateInvalid: (source) => {
return source.executeOne<any>({
otherType: () =>
source.executeOne<any>({
string: () => source.nextId(),
number: () => source.nextInt(100),
object: () => ({ key: source.nextId() }),
}),
invalidItem: () => {
const val = generateValid(source)
if (val.length === 0) {
return [valueType.generateInvalid(source)]
}
const indexToChange = source.nextInt(val.length)
val[indexToChange] = valueType.generateInvalid(source)
return val
},
})
},
}
}
function runTest(seed: number) {
test(`fuzz test with seed ${seed}`, () => {
const source = new RandomSource(seed)
const type = source.nextTestType(0)
const oldValid = type.generateValid(source)
const newValid = source.choice(0.5) ? type.generateValid(source) : oldValid
const didChange = !isEqual(oldValid, newValid)
const invalid = type.generateInvalid(source)
expect(type.validator.validate(oldValid)).toBe(oldValid)
expect(type.validator.validate(newValid)).toBe(newValid)
expect(() => {
type.validator.validate(invalid)
}).toThrow()
expect(() => type.validator.validateUsingKnownGoodVersion(oldValid, newValid)).not.toThrow()
expect(() => type.validator.validateUsingKnownGoodVersion(oldValid, invalid)).toThrow()
if (didChange) {
expect(type.validator.validateUsingKnownGoodVersion(oldValid, newValid)).toBe(newValid)
} else {
expect(type.validator.validateUsingKnownGoodVersion(oldValid, newValid)).toBe(oldValid)
}
})
}
const NUM_TESTS = 1000
const source = new RandomSource(Math.random())
// 54480484
const onlySeed: null | number = null
if (onlySeed) {
runTest(onlySeed)
} else {
for (let i = 0; i < NUM_TESTS; i++) {
const seed = source.nextInt(100000000)
runTest(seed)
}
}

View file

@ -59,7 +59,7 @@ describe('validations', () => {
x: 132, x: 132,
y: NaN, y: NaN,
}) })
).toThrowErrorMatchingInlineSnapshot(`"At shape().y: Expected a number, got NaN"`) ).toThrowErrorMatchingInlineSnapshot(`"At shape.y: Expected a number, got NaN"`)
expect(() => expect(() =>
T.model( T.model(
@ -70,7 +70,7 @@ describe('validations', () => {
}) })
).validate({ id: 'abc13', color: 'rubbish' }) ).validate({ id: 'abc13', color: 'rubbish' })
).toThrowErrorMatchingInlineSnapshot( ).toThrowErrorMatchingInlineSnapshot(
`"At shape().color: Expected "red" or "green" or "blue", got rubbish"` `"At shape.color: Expected "red" or "green" or "blue", got rubbish"`
) )
}) })

View file

@ -7559,6 +7559,7 @@ __metadata:
dependencies: dependencies:
"@tldraw/utils": "workspace:*" "@tldraw/utils": "workspace:*"
lazyrepo: "npm:0.0.0-alpha.27" lazyrepo: "npm:0.0.0-alpha.27"
lodash.isequal: "npm:^4.5.0"
languageName: unknown languageName: unknown
linkType: soft linkType: soft