Skip to content
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

Merged
merged 10 commits into from
Oct 23, 2023
Merged

Add .clone() method to Live structures #1245

merged 10 commits into from
Oct 23, 2023

Conversation

nvie
Copy link
Collaborator

@nvie nvie commented Oct 20, 2023

Fixes https://github.com/liveblocks/liveblocks.io/issues/1594, #560, and #1000.

This adds a .clone() method to LiveMap, LiveList, and LiveObject, 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:

const shapes = root.get("shapes");
const shape1 = shapes.get("shape1");
shapes.set("shape2", shape1.clone());

TODO

@nvie nvie self-assigned this Oct 20, 2023
Comment on lines +17 to +33
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][]))
),
})
);
Copy link
Collaborator Author

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.

Comment on lines 66 to 85
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);
}
)
));
Copy link
Collaborator Author

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]));
Copy link
Collaborator Author

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?

Copy link
Collaborator Author

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 not, it's a JSON value, so no need to clone anything

Copy link
Collaborator Author

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.

Copy link
Collaborator Author

@nvie nvie Oct 23, 2023

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".

cc @GuillaumeSalles

Footnotes

  1. 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!

Comment on lines 958 to 995
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();
}
}
});
Copy link
Collaborator Author

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.

Copy link
Contributor

@jrowny jrowny left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks good!

@nvie nvie merged commit 90cb806 into release-1.5 Oct 23, 2023
36 of 53 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

"Cannot set parent: node already has a parent"
2 participants