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
4 changes: 2 additions & 2 deletions api.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ Contents
- Mutate a draft: `(draft: InferType<S>) => void`
- Return a new object: `(prev: Readonly<InferInputType<S>>) => InferInputType<S>`
- Shallow partial: `Partial<InferInputType<S>>`
- `options?: { tags?: string | string[] }` — tags surface in subscriber metadata
- `options?: { tags?: string | string[]; origin?: string; timestamp?: number; message?: string }` — tags surface in subscriber metadata; commit metadata (`origin`, `timestamp`, `message`) is forwarded to the underlying Loro commit
- `subscribe((state, metadata) => void): () => void`
- `metadata: { direction: SyncDirection; tags?: string[] }`
- Returns an unsubscribe function
Expand All @@ -55,7 +55,7 @@ Contents
- `FROM_LORO` — changes applied from the Loro document
- `TO_LORO` — changes produced by `setState`
- `BIDIRECTIONAL` — manual/initial sync context
- Mirror ignores events with origin `"to-loro"` to prevent feedback loops.
- Mirror suppresses document events emitted during its own `setState` commits to prevent feedback loops; provide `origin`, `timestamp`, or `message` when you need to tag those commits.
- Initial state precedence: defaults (from schema) → `doc` snapshot (normalized) → hinted shapes from `initialState` (no writes to Loro).
- Trees: mirror state uses `{ id: string; data: object; children: Node[] }`. Loro tree `meta` is normalized to `data`.
- `$cid` on maps: Mirror injects a read-only `$cid` field into every LoroMap shape in state. It equals the Loro container ID, is not written back to Loro, and is ignored by diffs.
Expand Down
2 changes: 1 addition & 1 deletion packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ Trees are advanced usage; see Advanced: Trees at the end.
- Methods:
- getState(): Current state
- setState(updater | partial, options?): Mutate a draft or return a new object. Runs synchronously so downstream logic can immediately read the latest state.
- options: `{ tags?: string | string[] }` (surfaces in subscriber metadata)
- options: `{ tags?: string | string[]; origin?: string; timestamp?: number; message?: string }` — tags surface in subscriber metadata; commit metadata is forwarded to the underlying Loro commit.
- subscribe((state, metadata) => void): Subscribe; returns unsubscribe
- metadata: `{ direction: FROM_LORO | TO_LORO; tags?: string[] }`
- dispose(): Remove all subscriptions
Expand Down
56 changes: 50 additions & 6 deletions packages/core/src/core/mirror.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,19 @@ export interface SetStateOptions {
* Tags can be used for tracking the source of changes or grouping related changes
*/
tags?: string[] | string;
/**
* Optional origin metadata forwarded to the underlying Loro commit.
* Useful when callers need to tag commits with application-specific provenance.
*/
origin?: string;
/**
* Optional timestamp forwarded to the underlying Loro commit metadata.
*/
timestamp?: number;
/**
* Optional message forwarded to the underlying Loro commit metadata.
*/
message?: string;
}

type ContainerRegistry = Map<
Expand Down Expand Up @@ -573,7 +586,7 @@ export class Mirror<S extends SchemaType> {
* Handle events from the LoroDoc
*/
private handleLoroEvent = (event: LoroEventBatch) => {
if (event.origin === "to-loro") return;
if (this.syncing) return;
this.syncing = true;
try {
// Pre-register any containers referenced in this batch
Expand Down Expand Up @@ -713,7 +726,10 @@ export class Mirror<S extends SchemaType> {
/**
* Update Loro based on state changes
*/
private updateLoro(newState: InferType<S>) {
private updateLoro(
newState: InferType<S>,
options?: SetStateOptions,
) {
if (this.syncing) return;

this.syncing = true;
Expand All @@ -730,7 +746,7 @@ export class Mirror<S extends SchemaType> {
this.options?.inferOptions,
);
// Apply the changes to the Loro document (and stamp any pending-state metadata like $cid)
this.applyChangesToLoro(changes, newState);
this.applyChangesToLoro(changes, newState, options);
} finally {
this.syncing = false;
}
Expand All @@ -739,7 +755,11 @@ export class Mirror<S extends SchemaType> {
/**
* Apply a set of changes to the Loro document
*/
private applyChangesToLoro(changes: Change[], pendingState?: InferType<S>) {
private applyChangesToLoro(
changes: Change[],
pendingState?: InferType<S>,
options?: SetStateOptions,
) {
// Group changes by container for batch processing
const changesByContainer = new Map<ContainerID | "", Change[]>();

Expand Down Expand Up @@ -777,7 +797,31 @@ export class Mirror<S extends SchemaType> {
}
// Only commit if we actually applied any changes
if (changes.length > 0) {
this.doc.commit({ origin: "to-loro" });
let commitOptions: Parameters<LoroDoc["commit"]>[0];
if (options) {
const commitMeta: {
origin?: string;
timestamp?: number;
message?: string;
} = {};
if (options.origin !== undefined) {
commitMeta.origin = options.origin;
}
if (options.timestamp !== undefined) {
commitMeta.timestamp = options.timestamp;
}
if (options.message !== undefined) {
commitMeta.message = options.message;
}
if (
commitMeta.origin !== undefined ||
commitMeta.timestamp !== undefined ||
commitMeta.message !== undefined
) {
commitOptions = commitMeta;
}
}
this.doc.commit(commitOptions);
}
}

Expand Down Expand Up @@ -1767,7 +1811,7 @@ export class Mirror<S extends SchemaType> {
// Update Loro based on new state
// 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);
this.updateLoro(newState, options);
this.state = newState;
const shouldCheck = this.options.checkStateConsistency;
if (shouldCheck) {
Expand Down
27 changes: 16 additions & 11 deletions packages/core/tests/mirror-tree.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
/* eslint-disable unicorn/consistent-function-scoping */
import { describe, it, expect } from "vitest";
import { LoroDoc, LoroText } from "loro-crdt";
import { LoroDoc, LoroText, type LoroEventBatch } from "loro-crdt";
import { Mirror } from "../src/core/mirror";
import { applyEventBatchToState } from "../src/core/loroEventApply";
import { schema } from "../src/schema";
Expand Down Expand Up @@ -728,7 +728,7 @@ describe("LoroTree integration", () => {
await tick();
expect(m.getState().tree[0].data.tags).toEqual(["mid", "y"]);
});
it("FROM_LORO: ignores own origin 'to-loro' events to avoid feedback", async () => {
it("FROM_LORO: ignores mirror-produced events to avoid feedback", async () => {
const doc = new LoroDoc();
const s = schema({
tree: schema.LoroTree(schema.LoroMap({ title: schema.String() })),
Expand All @@ -743,7 +743,7 @@ describe("LoroTree integration", () => {
} as any);
await tick();

// Only TO_LORO notification should be recorded (FROM_LORO ignored due to origin)
// Only TO_LORO notification should be recorded (FROM_LORO ignored because we suppress local commits)
expect(directions.filter((d) => d === "TO_LORO").length).toBe(1);
expect(directions.filter((d) => d === "FROM_LORO").length).toBe(0);
});
Expand Down Expand Up @@ -1040,16 +1040,11 @@ describe("LoroTree integration", () => {
true,
]);

// Collect only the next TO_LORO event's tree diffs
// Collect only the tree diffs from the reorder commit
let lastTreeOps: any[] = [];
const batches: LoroEventBatch[] = [];
const unsub = doc.subscribe((batch) => {
// Mirror commits with origin "to-loro" for setState updates
if (batch.origin !== "to-loro") return;
for (const e of batch.events) {
if (e.diff.type === "tree") {
lastTreeOps.push(...e.diff.diff);
}
}
batches.push(batch);
});

// New state: reorder to C, A, B (by ids) – expect only moves, not full delete+create
Expand All @@ -1061,6 +1056,16 @@ describe("LoroTree integration", () => {
await tick();
unsub();

const lastBatch = batches[batches.length - 1];
expect(lastBatch).toBeDefined();
if (lastBatch) {
for (const e of lastBatch.events) {
if (e.diff.type === "tree") {
lastTreeOps.push(...e.diff.diff);
}
}
}

// Validate we only saw move operations (no full rebuild)
expect(lastTreeOps.length).toBeGreaterThan(0);
expect(lastTreeOps.every((op) => op.action === "move")).toBe(true);
Expand Down
139 changes: 139 additions & 0 deletions packages/core/tests/mirror.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,145 @@ describe("Mirror - State Consistency", () => {
doc = new LoroDoc();
});

it("forwards commit metadata through setState options", async () => {
const metaSchema = schema({
root: schema.LoroMap({
value: schema.String(),
}),
});

const commitSpy = vi.spyOn(doc, "commit");
const mirror = new Mirror({
doc,
schema: metaSchema,
});

commitSpy.mockClear();

const timestamp = Date.now();
const message = "update-from-ui";
const origin = "ui-panel";

mirror.setState(
{
root: {
value: "next",
},
} as any,
{ origin, timestamp, message },
);

await waitForSync();

expect(commitSpy).toHaveBeenCalledTimes(1);
expect(commitSpy.mock.calls[0][0]).toEqual({
origin,
timestamp,
message,
});

commitSpy.mockRestore();
mirror.dispose();
});

it("forwards partial commit metadata when only timestamp is provided", async () => {
const metaSchema = schema({
root: schema.LoroMap({
value: schema.String(),
}),
});

const commitSpy = vi.spyOn(doc, "commit");
const mirror = new Mirror({
doc,
schema: metaSchema,
});

commitSpy.mockClear();

const timestamp = Date.now();
mirror.setState(
{
root: {
value: "time-only",
},
} as any,
{ timestamp },
);

await waitForSync();

expect(commitSpy).toHaveBeenCalledTimes(1);
expect(commitSpy.mock.calls[0][0]).toEqual({ timestamp });

commitSpy.mockRestore();
mirror.dispose();
});

it("forwards partial commit metadata when only message is provided", async () => {
const metaSchema = schema({
root: schema.LoroMap({
value: schema.String(),
}),
});

const commitSpy = vi.spyOn(doc, "commit");
const mirror = new Mirror({
doc,
schema: metaSchema,
});

commitSpy.mockClear();

const message = "note";
mirror.setState(
{
root: {
value: "message-only",
},
} as any,
{ message },
);

await waitForSync();

expect(commitSpy).toHaveBeenCalledTimes(1);
expect(commitSpy.mock.calls[0][0]).toEqual({ message });

commitSpy.mockRestore();
mirror.dispose();
});

it("omits commit metadata when options are not provided", async () => {
const metaSchema = schema({
root: schema.LoroMap({
value: schema.String(),
}),
});

const commitSpy = vi.spyOn(doc, "commit");
const mirror = new Mirror({
doc,
schema: metaSchema,
});

commitSpy.mockClear();

mirror.setState({
root: {
value: "no-options",
},
} as any);

await waitForSync();

expect(commitSpy).toHaveBeenCalledTimes(1);
expect(commitSpy.mock.calls[0][0]).toBeUndefined();

commitSpy.mockRestore();
mirror.dispose();
});

it("syncs initial state from LoroDoc correctly", async () => {
// Set up initial Loro state
const todoMap = doc.getMap("todos");
Expand Down