-
Notifications
You must be signed in to change notification settings - Fork 240
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add .clone()
method to Live structures
#1245
Conversation
export const { liveList, liveMap, liveObject, liveStructure, lson } = fc.letrec( | ||
(tie) => ({ | ||
lson: fc.oneof(json, tie("liveStructure")).map((x) => x as Lson), | ||
liveStructure: fc | ||
.oneof(tie("liveList"), tie("liveMap"), tie("liveObject")) | ||
.map((x) => x as LiveStructure), | ||
liveList: fc.array(tie("lson")).map((x) => new LiveList(x as Lson[])), | ||
liveMap: fc | ||
.array(fc.tuple(key, tie("lson"))) | ||
.map((pairs) => new LiveMap(pairs as [string, Lson][])), | ||
liveObject: fc | ||
.array(fc.tuple(key, tie("lson"))) | ||
.map( | ||
(pairs) => new LiveObject(Object.fromEntries(pairs as [string, Lson][])) | ||
), | ||
}) | ||
); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These fast-check arbitraries will generate arbitrarily deep recursive LSON trees.
it("large/deep cloning", () => | ||
fc.assert( | ||
fc.asyncProperty( | ||
liveStructure, | ||
|
||
async (data) => { | ||
const { root } = await prepareStorageUpdateTest([ | ||
createSerializedObject("0:0", {}), | ||
]); | ||
|
||
// Clone "a" to "b" | ||
root.set("a", data); | ||
root.set("b", data.clone()); | ||
|
||
const imm = root.toImmutable(); | ||
expect(imm.a).toEqual(imm.b); | ||
} | ||
) | ||
)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here's the simplest test explained:
First, we use the liveStructure
Arbitrary (see above) to generate randomized LiveMap/LiveList/LiveObject structures. (Some deep, some shallow, some simple, some complex, etc.)
Then, we inject it into the root, under key "a"
. Next, we clone the value and inject it under key "b". Note that inserting the same value into the tree in different places is illegal and would lead to an error, e.g.
root.set("a", data);
root.set("b", data); // ❌ Would error!
But not when you clone it—then it's fine:
root.set("a", data);
root.set("b", data.clone()); // ✅ Fine!
Finally, we check that the cloned tree will produce the same immutable value for keys "a" and "b".
@@ -1341,7 +1342,7 @@ export function createRoom< | |||
const stackSizeBefore = context.undoStack.length; | |||
for (const key in context.initialStorage) { | |||
if (context.root.get(key) === undefined) { | |||
context.root.set(key, context.initialStorage[key]); | |||
context.root.set(key, cloneLson(context.initialStorage[key])); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@GuillaumeSalles What do you think of doing this when initializing initialStorage
? This would avoid the error from #1000 from ever showing up, even if people set it up using a global instance accidentally. I think this is pretty nice, but not sure if maybe you see a caveat with this?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We just chatted about this, and decided to change this slightly to the following behavior:
- We do a quick check to see if
context.initialStorage[key]
is a Live structure- If so, we check to see if it has a parent node set (i.e. it's part of a tree already)
- If it has a parent, we
.clone()
it, and issue a warning that this can be indicative of a bug in the way the app is configured, and that the user should always use fresh/new instances - If not, great, just use it
- If it has a parent, we
- If not, it's a JSON value, so no need to clone anything
- If so, we check to see if it has a parent node set (i.e. it's part of a tree already)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Will implement this next week.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Now that I started implementing this, I'm having second thoughts about this solution, because doing the optional cloning leads to situations that are maybe even more confusing, potentially.
Here's such a footgun:
- First, empty/new room A is entered with
initialStorage={initialStorage}
- Keys are missing, so the global
initialStorage
values are assigned, no cloning - User now just uses the room, mutating things as they go, populating the document with whatever content. Since no clone wat taken, this will also mutate the global
initialStorage
instance (unintentionally) - Now, the user joins empty/new room B with
initialStorage={initialStorage}
- Keys are missing, but since global
initialStorage
values are detected to already have a parent, a clone is taken, but… the clone will now contain all the mutations made in room A1!
This makes me conclude that the optional cloning tactic is maybe the worst solution even.
Therefore, I think that always cloning (i.e. what this PR implemented) is still be best pragmatic solution that leads to the fewest surprises — and reflects best how the prop is intended. We can document that whatever you pass as initialStorage
will be considered a "template".
Footnotes
-
Not only is this buggy, it also is inconsistent if you do the same thing again: mutate room B's contents, join new/empty room C. Now room C is another copy of room A, not B! ↩
test("missing storage keys are properly initialized using initialStorage, even when they are already part of the storage tree", async () => { | ||
const initialPresence = {}; | ||
const initialStorage = { | ||
list: new LiveList([13, 42]), | ||
map: new LiveMap([["a", 1]]), | ||
obj: new LiveObject({ x: 0 }), | ||
}; | ||
|
||
// Create the same room instance twice (using the same, global, | ||
// initialStorage instance) | ||
for (let i = 0; i < 2; i++) { | ||
const { room, wss } = createTestableRoom( | ||
initialPresence, | ||
undefined, | ||
undefined, | ||
undefined, | ||
initialStorage | ||
); | ||
wss.onConnection((conn) => { | ||
conn.server.send( | ||
serverMessage({ | ||
type: ServerMsgCode.INITIAL_STORAGE_STATE, | ||
items: [["root", { type: CrdtType.OBJECT, data: {} }]], | ||
// ^^ | ||
// NOTE: Storage is initially | ||
// empty, so initialStorage keys | ||
// will get added | ||
}) | ||
); | ||
}); | ||
room.connect(); | ||
try { | ||
await room.getStorage(); | ||
} finally { | ||
room.destroy(); | ||
} | ||
} | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This test fails on main
, but passes with this fix applied.
…initialStorage value twice works fine
This avoids the "Cannot set parent: node already has a parent" error.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
looks good!
… the second room when initialized
Fixes https://github.com/liveblocks/liveblocks.io/issues/1594, #560, and #1000.
This adds a
.clone()
method toLiveMap
,LiveList
, andLiveObject
, to make a deep clone of those values that are not attached to the pool, and thus are suitable for injection into another document, or in a different part of the Storage tree. Previously, users have been using this community-provided code snippet.This method can be useful to implement features like "duplicate selected layers" in a drawing app, for example.
Usage:
TODO
Only clone when needed, and issue a warning when we doUpdate: decided not to