Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions packages/core/src/core/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1282,6 +1282,34 @@ export function diffMap<S extends ObjectLike>(
const oldItem = oldStateObj[key];
const newItem = newStateObj[key];

// Treat undefined values as non-existent fields.
// This allows users to pass objects with undefined values without causing errors.
if (newItem === undefined) {
// If old item exists, we need to delete it
if (key in oldStateObj && oldItem !== undefined) {
const childSchemaForDelete = getMapChildSchema(
schema as
| LoroMapSchema<Record<string, SchemaType>>
| LoroMapSchemaWithCatchall<
Record<string, SchemaType>,
SchemaType
>
| RootSchemaType<Record<string, ContainerSchemaType>>
| undefined,
key,
);
if (!(childSchemaForDelete && childSchemaForDelete.type === "ignore")) {
changes.push({
container: containerId,
key,
value: undefined,
kind: "delete",
});
}
}
continue;
}

// Figure out if the modified new value is a container
const childSchema = getMapChildSchema(
schema as
Expand Down
7 changes: 6 additions & 1 deletion packages/core/src/core/mirror.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
tryInferContainerType,
getRootContainerByType,
defineCidProperty,
stripUndefined,
} from "./utils.js";
import { diffContainer, diffTree } from "./diff.js";
import { CID_KEY } from "../constants.js";
Expand Down Expand Up @@ -1644,6 +1645,8 @@ export class Mirror<S extends SchemaType> {
for (const [key, val] of Object.entries(value)) {
// Skip injected CID field
if (key === CID_KEY) continue;
// Skip undefined values - treat them as non-existent fields
if (val === undefined) continue;
if (mapSchema) {
const fieldSchema = this.getSchemaForMapKey(mapSchema, key);

Expand Down Expand Up @@ -2088,7 +2091,9 @@ export class Mirror<S extends SchemaType> {
// Refresh in-memory state from Doc to capture assigned IDs (e.g., TreeIDs)
// and any canonical normalization (like Tree meta->data mapping).
this.updateLoro(newState, options);
this.state = newState;
// Strip undefined values from the state to match LoroDoc behavior
// (undefined values are treated as non-existent fields)
this.state = stripUndefined(newState);
const shouldCheck = this.options.checkStateConsistency;
if (shouldCheck) {
this.checkStateConsistency();
Expand Down
79 changes: 79 additions & 0 deletions packages/core/src/core/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,85 @@ export function isObject(value: unknown): value is Record<string, unknown> {
);
}

// Keys that could cause prototype pollution if assigned directly
const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);

/**
* Recursively removes undefined values from an object.
* This treats undefined values as non-existent fields.
* Preserves non-enumerable properties like $cid.
* Returns the original object if no undefined values are found.
* Protects against prototype pollution by skipping unsafe keys.
*/
export function stripUndefined<T>(value: T): T {
if (value === undefined) {
return value;
}
if (Array.isArray(value)) {
let hasChanges = false;
const result = value.map((item) => {
const stripped = stripUndefined(item);
if (stripped !== item) hasChanges = true;
return stripped;
});
return hasChanges ? (result as T) : value;
}
if (isObject(value)) {
// Check if any enumerable property is undefined or needs stripping
let hasUndefined = false;
let hasNestedChanges = false;
const strippedValues: Map<string, unknown> = new Map();

for (const key of Object.keys(value)) {
// Skip unsafe keys to prevent prototype pollution
if (UNSAFE_KEYS.has(key)) {
continue;
}
const val = value[key];
if (val === undefined) {
hasUndefined = true;
} else {
const stripped = stripUndefined(val);
strippedValues.set(key, stripped);
if (stripped !== val) {
hasNestedChanges = true;
}
}
}

// If no changes needed, return original object
if (!hasUndefined && !hasNestedChanges) {
return value;
}

// Use Object.create(null) to avoid prototype pollution
const result = Object.create(null) as Record<string, unknown>;
// Copy non-enumerable properties (like $cid) first
const allProps = Object.getOwnPropertyNames(value);
for (const key of allProps) {
// Skip unsafe keys
if (UNSAFE_KEYS.has(key)) {
continue;
}
const descriptor = Object.getOwnPropertyDescriptor(value, key);
if (descriptor && !descriptor.enumerable) {
Object.defineProperty(result, key, descriptor);
}
}
// Copy the stripped values using Object.defineProperty to be safe
for (const [key, val] of strippedValues) {
Object.defineProperty(result, key, {
value: val,
writable: true,
enumerable: true,
configurable: true,
});
}
return result as T;
}
return value;
}

/**
* Performs a deep equality check between two values
*/
Expand Down
149 changes: 149 additions & 0 deletions packages/core/tests/undefined-in-map.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { describe, it, expect } from "vitest";
import { LoroDoc } from "loro-crdt";
import { Mirror, schema } from "../src/index.js";

describe("undefined values in Map should be treated as non-existent fields", () => {
it("should ignore undefined fields when setting state", async () => {
const testSchema = schema({
root: schema.LoroMap({
name: schema.String(),
age: schema.Number(),
}),
});

const doc = new LoroDoc();
const mirror = new Mirror({
doc,
schema: testSchema,
checkStateConsistency: true,
});

// Try to set a field to undefined - this should be treated as if the field doesn't exist
expect(() => {
mirror.setState({
root: {
name: "test",
age: undefined, // This undefined should be ignored
},
} as any);
}).not.toThrow();

const state = mirror.getState() as any;

// The name field should be set
expect(state.root.name).toBe("test");
// The age field should not exist in the state (key should not be present)
expect("age" in state.root).toBe(false);
expect(state.root.age).toBeUndefined();
});

it("should ignore undefined fields in nested maps", async () => {
const testSchema = schema({
root: schema.LoroMap({
user: schema.LoroMap({
name: schema.String(),
email: schema.String(),
}),
}),
});

const doc = new LoroDoc();
const mirror = new Mirror({
doc,
schema: testSchema,
checkStateConsistency: true,
});

expect(() => {
mirror.setState({
root: {
user: {
name: "John",
email: undefined, // Should be ignored
},
},
} as any);
}).not.toThrow();

const state = mirror.getState() as any;
expect(state.root.user.name).toBe("John");
// The email field should not exist in the state
expect("email" in state.root.user).toBe(false);
expect(state.root.user.email).toBeUndefined();
});

it("should handle undefined in schema.Any fields", async () => {
const testSchema = schema({
root: schema.LoroMap({
data: schema.Any(),
}),
});

const doc = new LoroDoc();
const mirror = new Mirror({
doc,
schema: testSchema,
checkStateConsistency: true,
});

expect(() => {
mirror.setState({
root: {
data: {
field1: "value",
field2: undefined, // Should be ignored
},
},
} as any);
}).not.toThrow();

const state = mirror.getState() as any;
expect(state.root.data.field1).toBe("value");
// The field2 should not exist in the state
expect("field2" in state.root.data).toBe(false);
expect(state.root.data.field2).toBeUndefined();
});

it("should delete existing field when set to undefined", async () => {
const testSchema = schema({
root: schema.LoroMap({
name: schema.String(),
age: schema.Number(),
}),
});

const doc = new LoroDoc();
const mirror = new Mirror({
doc,
schema: testSchema,
checkStateConsistency: true,
});

// First set both fields
mirror.setState({
root: {
name: "test",
age: 25,
},
} as any);

let state = mirror.getState() as any;
expect(state.root.name).toBe("test");
expect(state.root.age).toBe(25);
expect("age" in state.root).toBe(true);

// Now set age to undefined - it should be deleted
mirror.setState({
root: {
name: "test",
age: undefined,
},
} as any);

state = mirror.getState() as any;
expect(state.root.name).toBe("test");
// The age field should be deleted
expect("age" in state.root).toBe(false);
expect(state.root.age).toBeUndefined();
});
});