Skip to content

Commit 88b2382

Browse files
authored
perf: make validator faster by caching checked immer obj (#40)
1 parent c871a81 commit 88b2382

File tree

3 files changed

+197
-6
lines changed

3 files changed

+197
-6
lines changed

packages/core/src/core/mirror.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1737,7 +1737,6 @@ export class Mirror<S extends SchemaType> {
17371737
) as InferType<S>);
17381738

17391739
// Validate state if needed
1740-
// TODO: We don't need to validate the state that are already reviewed
17411740
if (this.options.validateUpdates) {
17421741
const validation =
17431742
this.schema && validateSchema(this.schema, newState);

packages/core/src/schema/validators.ts

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,28 @@ import {
1515
} from "./types";
1616
import { isObject } from "../core/utils";
1717

18+
const schemaValidationCache = new WeakMap<object, WeakSet<object>>();
19+
20+
function isCacheableValue(value: unknown): value is object {
21+
return typeof value === "object" && value !== null;
22+
}
23+
24+
function isSchemaValidated(schema: SchemaType, value: unknown): boolean {
25+
if (!isCacheableValue(value)) return false;
26+
const cache = schemaValidationCache.get(value);
27+
return cache?.has(schema) ?? false;
28+
}
29+
30+
function markSchemaValidated(schema: SchemaType, value: unknown): void {
31+
if (!isCacheableValue(value)) return;
32+
let cache = schemaValidationCache.get(value);
33+
if (!cache) {
34+
cache = new WeakSet<object>();
35+
schemaValidationCache.set(value, cache);
36+
}
37+
cache.add(schema);
38+
}
39+
1840
/**
1941
* Type guard for LoroMapSchema
2042
*/
@@ -103,6 +125,10 @@ export function validateSchema<S extends SchemaType>(
103125
return { valid: true };
104126
}
105127

128+
if (isSchemaValidated(schema, value)) {
129+
return { valid: true };
130+
}
131+
106132
// Validate based on schema type
107133
switch ((schema as BaseSchemaType).type) {
108134
case "string":
@@ -166,14 +192,16 @@ export function validateSchema<S extends SchemaType>(
166192
case "loro-list":
167193
if (!Array.isArray(value)) {
168194
errors.push("Value must be an array");
169-
} else if (isLoroListSchema(schema)) {
170-
// Validate each item in the list
195+
} else if (
196+
isLoroListSchema(schema) || isLoroMovableListSchema(schema)
197+
) {
198+
const itemSchema = schema.itemSchema;
171199
value.forEach((item, index) => {
172-
const result = validateSchema(schema.itemSchema, item);
200+
const result = validateSchema(itemSchema, item);
173201
if (!result.valid && result.errors) {
174202
// Prepend array index to each error
175203
const prefixedErrors = result.errors.map((err) =>
176-
`Item ${index}: ${err}`
204+
`Item ${index}: ${err}`,
177205
);
178206
errors.push(...prefixedErrors);
179207
}
@@ -297,7 +325,12 @@ export function validateSchema<S extends SchemaType>(
297325
}
298326
}
299327

300-
return errors.length > 0 ? { valid: false, errors } : { valid: true };
328+
if (errors.length === 0) {
329+
markSchemaValidated(schema, value);
330+
return { valid: true };
331+
}
332+
333+
return { valid: false, errors };
301334
}
302335

303336
/**
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import { LoroDoc } from "loro-crdt";
3+
import { Mirror } from "../src/core/mirror";
4+
import { schema, validateSchema } from "../src/schema";
5+
import * as schemaModule from "../src/schema";
6+
7+
describe("schema validator caching", () => {
8+
let doc: LoroDoc;
9+
const metaDefaults = {
10+
name: "Default Name",
11+
count: 0,
12+
};
13+
const schemaDefinition = schema({
14+
meta: schema.LoroMap({
15+
name: schema.String({ defaultValue: metaDefaults.name }),
16+
count: schema.Number({ defaultValue: metaDefaults.count }),
17+
}),
18+
});
19+
const metaSchema = schemaDefinition.definition.meta;
20+
21+
beforeEach(() => {
22+
doc = new LoroDoc();
23+
const metaMap = doc.getMap("meta");
24+
metaMap.set("name", metaDefaults.name);
25+
metaMap.set("count", metaDefaults.count);
26+
doc.commit();
27+
});
28+
29+
it("reuses validations for unchanged state objects across setState calls", () => {
30+
const validateSpy = vi.spyOn(schemaModule, "validateSchema");
31+
const originalValidate = metaSchema.options.validate;
32+
const metaValidateSpy = vi.fn(() => true);
33+
(
34+
metaSchema.options as typeof metaSchema.options & {
35+
validate?: (value: unknown) => boolean | string;
36+
}
37+
).validate = metaValidateSpy;
38+
39+
try {
40+
const mirror = new Mirror({
41+
doc,
42+
schema: schemaDefinition,
43+
});
44+
45+
validateSpy.mockClear();
46+
metaValidateSpy.mockClear();
47+
48+
mirror.setState((state) => ({
49+
...state,
50+
meta: { ...state.meta, name: "Reviewed" },
51+
}));
52+
53+
expect(metaValidateSpy.mock.calls.length).toBeGreaterThan(0);
54+
55+
validateSpy.mockClear();
56+
metaValidateSpy.mockClear();
57+
58+
mirror.setState((state) => state);
59+
60+
expect(validateSpy.mock.calls.length).toBe(1);
61+
expect(metaValidateSpy).not.toHaveBeenCalled();
62+
63+
validateSpy.mockClear();
64+
metaValidateSpy.mockClear();
65+
66+
mirror.setState((state) => ({
67+
...state,
68+
meta: { ...state.meta, count: state.meta.count + 1 },
69+
}));
70+
71+
expect(metaValidateSpy.mock.calls.length).toBe(1);
72+
expect(validateSpy.mock.calls.length).toBe(1);
73+
} finally {
74+
validateSpy.mockRestore();
75+
metaSchema.options.validate = originalValidate;
76+
}
77+
});
78+
});
79+
80+
describe("validateSchema detects violations", () => {
81+
it("rejects non-string for string schema", () => {
82+
const stringSchema = schema.String({ required: true });
83+
expect(validateSchema(stringSchema, "ok").valid).toBe(true);
84+
const invalid = validateSchema(stringSchema, 42);
85+
expect(invalid.valid).toBe(false);
86+
expect(invalid.errors).toBeDefined();
87+
});
88+
89+
it("rejects non-number for number schema", () => {
90+
const numberSchema = schema.Number({ required: true });
91+
expect(validateSchema(numberSchema, 1).valid).toBe(true);
92+
const invalid = validateSchema(numberSchema, "1");
93+
expect(invalid.valid).toBe(false);
94+
});
95+
96+
it("rejects non-boolean for boolean schema", () => {
97+
const booleanSchema = schema.Boolean({ required: true });
98+
expect(validateSchema(booleanSchema, true).valid).toBe(true);
99+
const invalid = validateSchema(booleanSchema, "true");
100+
expect(invalid.valid).toBe(false);
101+
});
102+
103+
it("rejects invalid loro-map field types", () => {
104+
const mapSchema = schema.LoroMap({
105+
name: schema.String({ required: true }),
106+
});
107+
const valid = { name: "abc" };
108+
expect(validateSchema(mapSchema, valid).valid).toBe(true);
109+
const invalid = validateSchema(mapSchema, { name: 123 });
110+
expect(invalid.valid).toBe(false);
111+
});
112+
113+
it("rejects invalid loro-list items", () => {
114+
const listSchema = schema.LoroList(schema.Number());
115+
expect(validateSchema(listSchema, [1, 2]).valid).toBe(true);
116+
const invalid = validateSchema(listSchema, [1, "2"]);
117+
expect(invalid.valid).toBe(false);
118+
});
119+
120+
it("rejects invalid loro-movable-list items", () => {
121+
const listSchema = schema.LoroMovableList(schema.String(), (item) => item);
122+
expect(validateSchema(listSchema, ["a"]).valid).toBe(true);
123+
const invalid = validateSchema(listSchema, ["a", 2]);
124+
expect(invalid.valid).toBe(false);
125+
});
126+
127+
it("rejects invalid loro-text content", () => {
128+
const textSchema = schema.LoroText({ required: true });
129+
expect(validateSchema(textSchema, "").valid).toBe(true);
130+
const invalid = validateSchema(textSchema, 123);
131+
expect(invalid.valid).toBe(false);
132+
});
133+
134+
it("rejects invalid loro-tree structure", () => {
135+
const treeSchema = schema.LoroTree(
136+
schema.LoroMap({ title: schema.String({ required: true }) }),
137+
);
138+
139+
const validTree = [
140+
{
141+
id: "node-1",
142+
data: { title: "Root" },
143+
children: [],
144+
},
145+
];
146+
expect(validateSchema(treeSchema, validTree).valid).toBe(true);
147+
148+
const invalidTree = [
149+
{
150+
id: "node-1",
151+
data: { title: 123 },
152+
children: [],
153+
},
154+
];
155+
const result = validateSchema(treeSchema, invalidTree);
156+
expect(result.valid).toBe(false);
157+
expect(result.errors).toBeDefined();
158+
});
159+
});

0 commit comments

Comments
 (0)