@loro-extended/change 2.0.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,13 +20,11 @@ export class MovableListRef<
20
20
  }
21
21
 
22
22
  move(from: number, to: number): void {
23
- this.assertMutable()
24
23
  this.container.move(from, to)
25
24
  this.commitIfAuto()
26
25
  }
27
26
 
28
27
  set(index: number, item: Exclude<Item, Container>) {
29
- this.assertMutable()
30
28
  const result = this.container.set(index, item)
31
29
  this.commitIfAuto()
32
30
  return result
@@ -0,0 +1,214 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { change, createTypedDoc, Shape } from "../index.js"
3
+
4
+ /**
5
+ * Regression tests for Shape.record() value updates across multiple change() calls.
6
+ *
7
+ * These tests ensure that value shapes in records are always read fresh from the
8
+ * underlying container, preventing stale cache issues when mutations occur in
9
+ * separate change() transactions.
10
+ *
11
+ * Fix: RecordRef.getOrCreateRef() no longer caches value shapes - it always reads
12
+ * from the container. Container shapes (handles) are still cached safely.
13
+ */
14
+ describe("Record value updates across change() calls", () => {
15
+ describe("updating existing keys", () => {
16
+ it("updates value with .set() method", () => {
17
+ const Schema = Shape.doc({
18
+ input: Shape.record(Shape.plain.any()),
19
+ })
20
+
21
+ const doc = createTypedDoc(Schema)
22
+
23
+ change(doc, draft => {
24
+ draft.input.set("key1", 123)
25
+ })
26
+ expect(doc.input.get("key1")).toBe(123)
27
+
28
+ change(doc, draft => {
29
+ draft.input.set("key1", 456)
30
+ })
31
+ expect(doc.input.get("key1")).toBe(456)
32
+ })
33
+
34
+ it("updates value with indexed assignment", () => {
35
+ const Schema = Shape.doc({
36
+ input: Shape.record(Shape.plain.number()),
37
+ })
38
+
39
+ const doc = createTypedDoc(Schema)
40
+
41
+ change(doc, draft => {
42
+ draft.input.key1 = 100
43
+ })
44
+ expect(doc.input.key1).toBe(100)
45
+
46
+ change(doc, draft => {
47
+ draft.input.key1 = 200
48
+ })
49
+ expect(doc.input.key1).toBe(200)
50
+ })
51
+
52
+ it("handles multiple sequential updates to same key", () => {
53
+ const Schema = Shape.doc({
54
+ counter: Shape.record(Shape.plain.number()),
55
+ })
56
+
57
+ const doc = createTypedDoc(Schema)
58
+
59
+ for (let i = 1; i <= 5; i++) {
60
+ change(doc, draft => {
61
+ draft.counter.set("count", i)
62
+ })
63
+ expect(doc.counter.get("count")).toBe(i)
64
+ }
65
+ })
66
+ })
67
+
68
+ describe("deleting keys", () => {
69
+ it("removes key after delete", () => {
70
+ const Schema = Shape.doc({
71
+ input: Shape.record(Shape.plain.any()),
72
+ })
73
+
74
+ const doc = createTypedDoc(Schema)
75
+
76
+ change(doc, draft => {
77
+ draft.input.set("key1", "hello")
78
+ })
79
+ expect(doc.input.get("key1")).toBe("hello")
80
+
81
+ change(doc, draft => {
82
+ draft.input.delete("key1")
83
+ })
84
+ expect(doc.input.has("key1")).toBe(false)
85
+ expect(doc.input.get("key1")).toBeUndefined()
86
+ })
87
+ })
88
+
89
+ describe("toJSON() consistency", () => {
90
+ it("reflects updates in toJSON()", () => {
91
+ const Schema = Shape.doc({
92
+ input: Shape.record(Shape.plain.boolean()),
93
+ })
94
+
95
+ const doc = createTypedDoc(Schema)
96
+
97
+ change(doc, draft => {
98
+ draft.input.set("flag", false)
99
+ })
100
+ expect(doc.toJSON().input).toEqual({ flag: false })
101
+
102
+ change(doc, draft => {
103
+ draft.input.set("flag", true)
104
+ })
105
+ expect(doc.toJSON().input).toEqual({ flag: true })
106
+ })
107
+ })
108
+
109
+ describe("edge cases", () => {
110
+ it("handles setting same value then different value", () => {
111
+ const Schema = Shape.doc({
112
+ data: Shape.record(Shape.plain.number()),
113
+ })
114
+
115
+ const doc = createTypedDoc(Schema)
116
+
117
+ change(doc, draft => {
118
+ draft.data.set("x", 42)
119
+ })
120
+
121
+ change(doc, draft => {
122
+ draft.data.set("x", 42)
123
+ })
124
+
125
+ change(doc, draft => {
126
+ draft.data.set("x", 99)
127
+ })
128
+
129
+ expect(doc.data.get("x")).toBe(99)
130
+ })
131
+
132
+ it("handles alternating boolean values", () => {
133
+ const Schema = Shape.doc({
134
+ flags: Shape.record(Shape.plain.boolean()),
135
+ })
136
+
137
+ const doc = createTypedDoc(Schema)
138
+
139
+ change(doc, draft => {
140
+ draft.flags.set("active", false)
141
+ })
142
+ expect(doc.flags.get("active")).toBe(false)
143
+
144
+ change(doc, draft => {
145
+ draft.flags.set("active", true)
146
+ })
147
+ expect(doc.flags.get("active")).toBe(true)
148
+
149
+ change(doc, draft => {
150
+ draft.flags.set("active", false)
151
+ })
152
+ expect(doc.flags.get("active")).toBe(false)
153
+ })
154
+
155
+ it("handles null values", () => {
156
+ const Schema = Shape.doc({
157
+ nullable: Shape.record(Shape.plain.any()),
158
+ })
159
+
160
+ const doc = createTypedDoc(Schema)
161
+
162
+ change(doc, draft => {
163
+ draft.nullable.set("value", "initial")
164
+ })
165
+ expect(doc.nullable.get("value")).toBe("initial")
166
+
167
+ change(doc, draft => {
168
+ draft.nullable.set("value", null)
169
+ })
170
+ expect(doc.nullable.get("value")).toBe(null)
171
+ })
172
+
173
+ it("handles reading before first change", () => {
174
+ const Schema = Shape.doc({
175
+ data: Shape.record(Shape.plain.any()),
176
+ })
177
+
178
+ const doc = createTypedDoc(Schema)
179
+
180
+ expect(doc.data.get("key")).toBeUndefined()
181
+
182
+ change(doc, draft => {
183
+ draft.data.set("key", 100)
184
+ })
185
+ expect(doc.data.get("key")).toBe(100)
186
+
187
+ change(doc, draft => {
188
+ draft.data.set("key", 200)
189
+ })
190
+ expect(doc.data.get("key")).toBe(200)
191
+ })
192
+ })
193
+
194
+ describe("raw LoroDoc comparison", () => {
195
+ it("underlying CRDT operations work correctly", async () => {
196
+ const { LoroDoc } = await import("loro-crdt")
197
+
198
+ const doc = new LoroDoc()
199
+ const map = doc.getMap("input")
200
+
201
+ map.set("key", 123)
202
+ doc.commit()
203
+ expect(map.get("key")).toBe(123)
204
+
205
+ map.set("key", 456)
206
+ doc.commit()
207
+ expect(map.get("key")).toBe(456)
208
+
209
+ map.delete("key")
210
+ doc.commit()
211
+ expect(map.get("key")).toBeUndefined()
212
+ })
213
+ })
214
+ })
@@ -1,6 +1,5 @@
1
1
  import type { Container, LoroMap, Value } from "loro-crdt"
2
2
  import { deriveShapePlaceholder } from "../derive-placeholder.js"
3
- import { mergeValue } from "../overlay.js"
4
3
  import type {
5
4
  ContainerOrValueShape,
6
5
  ContainerShape,
@@ -16,7 +15,6 @@ import {
16
15
  createContainerTypedRef,
17
16
  hasContainerConstructor,
18
17
  serializeRefToJSON,
19
- unwrapReadonlyPrimitive,
20
18
  } from "./utils.js"
21
19
 
22
20
  // Record typed ref
@@ -67,8 +65,8 @@ export class RecordRef<
67
65
  placeholder,
68
66
  getContainer: () =>
69
67
  this.container.getOrCreateContainer(key, new (LoroContainer as any)()),
70
- readonly: this.readonly,
71
68
  autoCommit: this._params.autoCommit,
69
+ batchedMutation: this.batchedMutation,
72
70
  getDoc: this._params.getDoc,
73
71
  }
74
72
  }
@@ -96,43 +94,64 @@ export class RecordRef<
96
94
  * This is the method used for write operations.
97
95
  */
98
96
  getOrCreateRef(key: string): any {
99
- let ref = this.refCache.get(key)
100
- if (!ref) {
101
- const shape = this.shape.shape
102
- if (isContainerShape(shape)) {
103
- ref = createContainerTypedRef(
104
- this.getTypedRefParams(key, shape as ContainerShape),
105
- )
106
- // Cache container refs
107
- this.refCache.set(key, ref)
108
- } else {
109
- // For value shapes, first try to get the value from the container
97
+ const shape = this.shape.shape
98
+
99
+ if (isValueShape(shape)) {
100
+ // When NOT in batchedMutation mode (direct access outside of change()), ALWAYS read fresh
101
+ // from container (NEVER cache). This ensures we always get the latest value
102
+ // from the CRDT, even when modified by a different ref instance (e.g., drafts from change())
103
+ //
104
+ // When in batchedMutation mode (inside change()), we cache value shapes so that
105
+ // mutations to nested objects persist back to the CRDT via absorbPlainValues()
106
+ if (!this.batchedMutation) {
110
107
  const containerValue = this.container.get(key)
111
108
  if (containerValue !== undefined) {
112
- ref = containerValue as Value
109
+ return containerValue
110
+ }
111
+ // Fall back to placeholder if the container doesn't have the value
112
+ const placeholder = (this.placeholder as any)?.[key]
113
+ if (placeholder !== undefined) {
114
+ return placeholder
115
+ }
116
+ // Fall back to the default value from the shape
117
+ return (shape as any)._plain
118
+ }
119
+
120
+ // In batched mode (within change()), we cache value shapes so that
121
+ // mutations to nested objects persist back to the CRDT via absorbPlainValues()
122
+ let ref = this.refCache.get(key)
123
+ if (!ref) {
124
+ const containerValue = this.container.get(key)
125
+ if (containerValue !== undefined) {
126
+ // For objects, create a deep copy so mutations can be tracked
127
+ if (typeof containerValue === "object" && containerValue !== null) {
128
+ ref = JSON.parse(JSON.stringify(containerValue))
129
+ } else {
130
+ ref = containerValue as Value
131
+ }
113
132
  } else {
114
- // Only fall back to placeholder if the container doesn't have the value
133
+ // Fall back to placeholder if the container doesn't have the value
115
134
  const placeholder = (this.placeholder as any)?.[key]
116
- if (placeholder === undefined) {
117
- // If it's a value type and not in container or placeholder,
118
- // fallback to the default value from the shape
119
- ref = (shape as any)._plain
120
- } else {
135
+ if (placeholder !== undefined) {
121
136
  ref = placeholder as Value
137
+ } else {
138
+ // Fall back to the default value from the shape
139
+ ref = (shape as any)._plain
122
140
  }
123
141
  }
124
- // Only cache primitive values if NOT readonly
125
- if (ref !== undefined && !this.readonly) {
126
- this.refCache.set(key, ref)
127
- }
142
+ this.refCache.set(key, ref)
128
143
  }
144
+ return ref
129
145
  }
130
146
 
131
- if (this.readonly && isContainerShape(this.shape.shape)) {
132
- return unwrapReadonlyPrimitive(
133
- ref as TypedRef<any>,
134
- this.shape.shape as ContainerShape,
147
+ // For container shapes, we can safely cache the ref since it's a handle
148
+ // to the underlying Loro container, not a value copy.
149
+ let ref = this.refCache.get(key)
150
+ if (!ref) {
151
+ ref = createContainerTypedRef(
152
+ this.getTypedRefParams(key, shape as ContainerShape),
135
153
  )
154
+ this.refCache.set(key, ref)
136
155
  }
137
156
 
138
157
  return ref as any
@@ -143,7 +162,6 @@ export class RecordRef<
143
162
  }
144
163
 
145
164
  set(key: string, value: any): void {
146
- this.assertMutable()
147
165
  if (isValueShape(this.shape.shape)) {
148
166
  this.container.set(key, value)
149
167
  this.refCache.set(key, value)
@@ -163,14 +181,12 @@ export class RecordRef<
163
181
  }
164
182
 
165
183
  setContainer<C extends Container>(key: string, container: C): C {
166
- this.assertMutable()
167
184
  const result = this.container.setContainer(key, container)
168
185
  this.commitIfAuto()
169
186
  return result
170
187
  }
171
188
 
172
189
  delete(key: string): void {
173
- this.assertMutable()
174
190
  this.container.delete(key)
175
191
  this.refCache.delete(key)
176
192
  this.commitIfAuto()
@@ -193,25 +209,6 @@ export class RecordRef<
193
209
  }
194
210
 
195
211
  toJSON(): Record<string, Infer<NestedShape>> {
196
- // Fast path: readonly mode
197
- if (this.readonly) {
198
- const nativeJson = this.container.toJSON() as Record<string, any>
199
- // For records, we need to overlay placeholders for each entry's nested shape
200
- const result: Record<string, Infer<NestedShape>> = {}
201
- for (const key of Object.keys(nativeJson)) {
202
- // For records, the placeholder is always {}, so we need to derive
203
- // the placeholder for the nested shape on the fly
204
- const nestedPlaceholderValue = deriveShapePlaceholder(this.shape.shape)
205
-
206
- result[key] = mergeValue(
207
- this.shape.shape,
208
- nativeJson[key],
209
- nestedPlaceholderValue as Value,
210
- ) as Infer<NestedShape>
211
- }
212
- return result
213
- }
214
-
215
212
  return serializeRefToJSON(this, this.keys()) as Record<
216
213
  string,
217
214
  Infer<NestedShape>
@@ -0,0 +1,200 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { change, createTypedDoc, Shape } from "../index.js"
3
+
4
+ /**
5
+ * Tests for StructRef value updates across multiple change() calls.
6
+ *
7
+ * These tests verify that value shapes in structs are always read fresh from the
8
+ * underlying container, preventing stale cache issues when mutations occur in
9
+ * separate change() transactions.
10
+ *
11
+ * BUG: StructRef.getOrCreateRef() caches value shapes in propertyCache, causing
12
+ * stale values to be returned when the underlying container is modified by a
13
+ * different StructRef instance (e.g., drafts created by change()).
14
+ */
15
+ describe("Struct value updates across change() calls", () => {
16
+ describe("updating existing properties", () => {
17
+ it("updates value property via direct assignment", () => {
18
+ const Schema = Shape.doc({
19
+ config: Shape.struct({
20
+ name: Shape.plain.string(),
21
+ count: Shape.plain.number(),
22
+ }),
23
+ })
24
+
25
+ const doc = createTypedDoc(Schema)
26
+
27
+ change(doc, draft => {
28
+ draft.config.name = "initial"
29
+ draft.config.count = 1
30
+ })
31
+ expect(doc.config.name).toBe("initial")
32
+ expect(doc.config.count).toBe(1)
33
+
34
+ // Second change - BUG: values stay at initial values
35
+ change(doc, draft => {
36
+ draft.config.name = "updated"
37
+ draft.config.count = 2
38
+ })
39
+ expect(doc.config.name).toBe("updated") // FAILS: returns "initial"
40
+ expect(doc.config.count).toBe(2) // FAILS: returns 1
41
+ })
42
+
43
+ it("handles multiple sequential updates to same property", () => {
44
+ const Schema = Shape.doc({
45
+ settings: Shape.struct({
46
+ value: Shape.plain.number(),
47
+ }),
48
+ })
49
+
50
+ const doc = createTypedDoc(Schema)
51
+
52
+ for (let i = 1; i <= 5; i++) {
53
+ change(doc, draft => {
54
+ draft.settings.value = i
55
+ })
56
+ expect(doc.settings.value).toBe(i) // FAILS on i > 1
57
+ }
58
+ })
59
+
60
+ it("updates boolean property", () => {
61
+ const Schema = Shape.doc({
62
+ flags: Shape.struct({
63
+ enabled: Shape.plain.boolean(),
64
+ }),
65
+ })
66
+
67
+ const doc = createTypedDoc(Schema)
68
+
69
+ change(doc, draft => {
70
+ draft.flags.enabled = true
71
+ })
72
+ expect(doc.flags.enabled).toBe(true)
73
+
74
+ change(doc, draft => {
75
+ draft.flags.enabled = false
76
+ })
77
+ expect(doc.flags.enabled).toBe(false) // FAILS: returns true
78
+ })
79
+ })
80
+
81
+ describe("nested structs", () => {
82
+ it("updates value in nested struct", () => {
83
+ const Schema = Shape.doc({
84
+ outer: Shape.struct({
85
+ inner: Shape.struct({
86
+ value: Shape.plain.number(),
87
+ }),
88
+ }),
89
+ })
90
+
91
+ const doc = createTypedDoc(Schema)
92
+
93
+ change(doc, draft => {
94
+ draft.outer.inner.value = 100
95
+ })
96
+ expect(doc.outer.inner.value).toBe(100)
97
+
98
+ change(doc, draft => {
99
+ draft.outer.inner.value = 200
100
+ })
101
+ expect(doc.outer.inner.value).toBe(200) // FAILS: returns 100
102
+ })
103
+ })
104
+
105
+ describe("struct inside record", () => {
106
+ it("updates value in struct that is a record value", () => {
107
+ const Schema = Shape.doc({
108
+ users: Shape.record(
109
+ Shape.struct({
110
+ name: Shape.plain.string(),
111
+ age: Shape.plain.number(),
112
+ }),
113
+ ),
114
+ })
115
+
116
+ const doc = createTypedDoc(Schema)
117
+
118
+ change(doc, draft => {
119
+ draft.users.user1 = { name: "Alice", age: 30 }
120
+ })
121
+ expect(doc.users.user1?.name).toBe("Alice")
122
+ expect(doc.users.user1?.age).toBe(30)
123
+
124
+ // Update the struct's value properties
125
+ change(doc, draft => {
126
+ const user = draft.users.getOrCreateRef("user1")
127
+ user.name = "Bob"
128
+ user.age = 25
129
+ })
130
+ expect(doc.users.user1?.name).toBe("Bob") // FAILS: returns "Alice"
131
+ expect(doc.users.user1?.age).toBe(25) // FAILS: returns 30
132
+ })
133
+ })
134
+
135
+ describe("toJSON() consistency", () => {
136
+ it("reflects updates in toJSON()", () => {
137
+ const Schema = Shape.doc({
138
+ data: Shape.struct({
139
+ status: Shape.plain.string(),
140
+ }),
141
+ })
142
+
143
+ const doc = createTypedDoc(Schema)
144
+
145
+ change(doc, draft => {
146
+ draft.data.status = "pending"
147
+ })
148
+ expect(doc.toJSON().data).toEqual({ status: "pending" })
149
+
150
+ change(doc, draft => {
151
+ draft.data.status = "complete"
152
+ })
153
+ expect(doc.toJSON().data).toEqual({ status: "complete" })
154
+ })
155
+ })
156
+
157
+ describe("reading before first change", () => {
158
+ it("reading before any change should not cause stale cache", () => {
159
+ const Schema = Shape.doc({
160
+ config: Shape.struct({
161
+ value: Shape.plain.number().placeholder(0),
162
+ }),
163
+ })
164
+
165
+ const doc = createTypedDoc(Schema)
166
+
167
+ // Read before any change - gets placeholder value
168
+ expect(doc.config.value).toBe(0)
169
+
170
+ // First set
171
+ change(doc, draft => {
172
+ draft.config.value = 100
173
+ })
174
+ expect(doc.config.value).toBe(100)
175
+
176
+ // Second set - this is the key test for the stale cache bug
177
+ change(doc, draft => {
178
+ draft.config.value = 200
179
+ })
180
+ expect(doc.config.value).toBe(200) // FAILS: returns 100
181
+ })
182
+ })
183
+
184
+ describe("comparison with raw LoroDoc", () => {
185
+ it("underlying CRDT operations work correctly", async () => {
186
+ const { LoroDoc } = await import("loro-crdt")
187
+
188
+ const doc = new LoroDoc()
189
+ const map = doc.getMap("config")
190
+
191
+ map.set("value", 123)
192
+ doc.commit()
193
+ expect(map.get("value")).toBe(123)
194
+
195
+ map.set("value", 456)
196
+ doc.commit()
197
+ expect(map.get("value")).toBe(456) // PASSES: raw Loro works fine
198
+ })
199
+ })
200
+ })