Skip to content

Commit

Permalink
♻️ refactor: Rewrite for stability and code cleanliness
Browse files Browse the repository at this point in the history
Rewrite for stability and code cleanliness
  • Loading branch information
joebobmiles committed Jul 13, 2021
2 parents 459e942 + fa01be5 commit d1bd6e7
Show file tree
Hide file tree
Showing 9 changed files with 1,366 additions and 255 deletions.
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
*.ts eol=lf
*.js eol=lf
*.json eol=lf
14 changes: 14 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "attach",
"name": "Attach",
"port": 9229
}
]
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
},
"scripts": {
"test": "jest",
"test:debug": "node --inspect-brk ./node_modules/jest/bin/jest.js --runInBand",
"build": "tsc",
"prepare": "husky install"
},
Expand Down
74 changes: 34 additions & 40 deletions readme.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,13 @@
# Yjs Middleware for Zustand

> **This project is currently in a minimum-viable product (MVP) state.**
> Consider the current state to be pre-alpha and expect a wealth of bugs. I will
> be undertaking a major rewrite of this library to lock in requirements,
> functionality, and scope.
One of the difficult things about using Yjs is that it's not easily integrated
with modern state management libraries in React. This middleware for Zustand
solves that problem by allowing a Zustand store to be turned into a CRDT, with
the store's state replicated to all peers.

This differs from the other Yjs and Zustand solution, `zustand-yjs` by allowing
any Zustand store be turned into a CRDT. This contrasts with `zustand-yjs`'s solution, which uses a Zustand store to collect shared types and access them
any Zustand store be turned into a CRDT. This contrasts with `zustand-yjs`'s
solution, which uses a Zustand store to collect shared types and access them
through special hooks.

Because this solution is simply a middleware, it can also work anywhere Zustand
Expand All @@ -34,53 +30,51 @@ const ydoc = new Y.Doc();

// Create the Zustand store.
const useSharedStore = create(
// Wrap the store creator with the Yjs middleware.
yjs(
// Provide the Y Doc and the name of the shared type that will be used
// to hold the store.
ydoc, "shared",

// Create the store as you would normally.
(set) =>
({
count: 0,
increment: set(
(state) =>
({
count: state.count + 1,
})
),
})
)
// Wrap the store creator with the Yjs middleware.
yjs(
// Provide the Y Doc and the name of the shared type that will be used
// to hold the store.
ydoc, "shared",
// Create the store as you would normally.
(set) =>
({
count: 0,
increment: set(
(state) =>
({
count: state.count + 1,
})
),
})
)
);

// Use the shared store like you normally would any other Zustand store.
const App = () =>
{
const { count, increment } = useSharedState((state) => {
count: state.count, increment: state.increment
});

return (
<>
<p>count: {count}</p>
<button onClick={() => increment()}>+</button>
</>
)
const { count, increment } = useSharedState((state) =>
({
count: state.count,
increment: state.increment
}));

return (
<>
<p>count: {count}</p>
<button onClick={() => increment()}>+</button>
</>
);
};

render(
<App />,
document.getElementById("app-root")
<App />,
document.getElementById("app-root")
);
```

## Caveats

1. **This project is currently in a minimum-viable product (MVP) state.**
Consider the current state to be pre-alpha and expect a wealth of bugs.
I will be undertaking a major rewrite of this library to lock in
requirements, functionality, and scope.
1. Currently the Y Text shared type is not supported. This means that strings
in the store do not benefit from the conflict-resolution performed by Yjs.
1. The Yjs awareness protocol is not supported. At the moment, it is unclear
Expand Down
231 changes: 16 additions & 215 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,167 +6,7 @@ import {
StoreApi,
} from "zustand/vanilla";
import * as Y from "yjs";
import { diff, } from "json-diff";

const arrayToYarray = (array: Array<any>): Y.Array<any> =>
{
const yarray = new Y.Array();

for (const value of array)
{
if (typeof value !== "function" && typeof value !== "undefined")
{
if (value instanceof Array)

yarray.push([ arrayToYarray(value) ]);

else if (value instanceof Object)

yarray.push([ stateToYmap(value) ]);

else

yarray.push([ value ]);

}
}

return yarray;
};

const stateToYmap = <S extends State>(state: S, ymap = new Y.Map()) =>
{
for (const property in state)
{
if (typeof state[property] !== "function" && typeof state[property] !== "undefined")
{
if (state[property] instanceof Array)
ymap.set(property, arrayToYarray((<unknown>state[property]) as Array<any>));


else if (state[property] instanceof Object)
ymap.set(property, stateToYmap((state as any)[property]));


else
ymap.set(property, state[property]);

}
}

return ymap;
};

const mapZustandUpdateToYjsUpdate =
(stateDiff: any, sharedType: Y.Map<any> | Y.Array<any>) =>
{
const getChange = (property: string, value: any): [
"add" | "delete" | "update" | "none",
string,
any
] =>
{
if (isNaN(parseInt(property, 10)) === false)
{
switch (value[0])
{
case "+":
return [ "add", property, value[1] ];

case "-":
return [ "delete", property, undefined ];

default:
return [ "none", property, value[1] ];
}
}
else
{
if (property.match(/__added$/))
return [ "add", property.replace(/__added$/, ""), value ];

else if (property.match(/__deleted$/))
return [ "delete", property.replace(/__deleted$/, ""), undefined ];

else if (value.__old !== undefined && value.__new !== undefined)
return [ "update", property, value.__new ];

else
return [ "none", property, value ];
}
};

for (const property in stateDiff)
{
const value = stateDiff[property];

if (typeof value !== "function" && typeof value !== "undefined")
{
const [ type, actualProperty, newValue ] = getChange(property, value);

if (typeof newValue !== "function" && typeof newValue !== "undefined")
{
switch (type)
{
case "delete":
// TODO
break;

case "add":
case "update":
{
if (newValue instanceof Object)
return;

else
{
if (sharedType instanceof Y.Map)
sharedType.set(actualProperty, newValue);

else if (sharedType instanceof Y.Array)
{
const index = parseInt(actualProperty, 10);

const left = sharedType.slice(0, index);
const right = sharedType.slice(index+1);

sharedType.doc?.transact(() =>
{
sharedType.delete(0, sharedType.length);
sharedType.insert(0, [ ...left, newValue, ...right ]);
});
}
}
}
break;

case "none":
default:
{
if (newValue instanceof Object)
{
if (sharedType instanceof Y.Map)
{
mapZustandUpdateToYjsUpdate(
newValue,
sharedType.get(actualProperty)
);
}
else if (sharedType instanceof Y.Array)
{
mapZustandUpdateToYjsUpdate(
newValue,
sharedType.get(parseInt(actualProperty, 10))
);
}
}
}
break;
}
}
}
}
};
import { patchSharedType, patchStore, } from "./patching";

/**
* This function is the middleware the sets up the Zustand store to mirror state
Expand All @@ -187,79 +27,40 @@ export const yjs = <S extends State>(
const map: Y.Map<any> = doc.getMap(name);

// Augment the store.
return (_set: SetState<S>, _get: GetState<S>, _api: StoreApi<S>): S =>
return (set: SetState<S>, get: GetState<S>, api: StoreApi<S>): S =>
{
// The new set function.
const set: SetState<S> = (partial, replace) =>
{
const previousState = _get();
_set(partial, replace);
const nextState = _get();

mapZustandUpdateToYjsUpdate(diff(previousState, nextState), map);
};

// The new get function.
const get: GetState<S> = () =>
_get();

/*
* Capture the initial state so that we can initialize the Yjs store to the
* same values as the initial values of the Zustand store.
*/
const initialState = config(
set,
(partial, replace) =>
{
set(partial, replace);
patchSharedType(map, get());
},
get,
{
..._api,
"setState": set,
"getState": get,
...api,
"setState": (partial, replace) =>
{
api.setState(partial, replace);
patchSharedType(map, get());
},
}
);

// Initialize the Yjs store.
stateToYmap(initialState, map);
patchSharedType(map, initialState);

/*
* Whenever the Yjs store changes, we perform a set operation on the local
* Zustand store. We avoid using the Yjs enabled set to prevent unnecessary
* ping-pong of updates.
*/
map.observe((event) =>
map.observeDeep(() =>
{
if (event.target === map)
{
event.changes.keys.forEach((change, key) =>
{
switch (change.action)
{
case "add":
case "update":
set(() =>
{

const value = map.get(key);

if (value instanceof Y.Array)
{
console.log(value);
return <unknown>{ [key]: (value as Y.Array<any>).toJSON(), };
}

else if (value instanceof Y.Map)
return <unknown>{ [key]: (value as Y.Map<any>).toJSON(), };

else
return <unknown>{ [key]: value, };
});
break;

case "delete":
default:
break;
}
});
}
patchStore(api, map.toJSON());
});

// Return the initial state to create or the next middleware.
Expand Down
Loading

0 comments on commit d1bd6e7

Please sign in to comment.