Skip to content
Open
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
661 changes: 661 additions & 0 deletions packages/collaborative-vue/LICENSE

Large diffs are not rendered by default.

167 changes: 167 additions & 0 deletions packages/collaborative-vue/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
# JSON CRDT integrations with Vue.js

Vue 3 composables, provide/inject context helpers, and render-prop components for
binding Vue UI to [`json-joy`](https://github.com/streamich/json-joy) CRDT models
and nodes. The Vue counterpart of
[`@jsonjoy.com/collaborative-react`](https://github.com/streamich/json-joy/tree/master/packages/collaborative-react)
— same surface, same names, Vue reactivity instead of React state.

## Installation

```bash
npm install json-joy @jsonjoy.com/collaborative-vue vue
```

## What this package provides

- **Context helpers** for `Model` and `NodeApi` (Vue `provide`/`inject`)
- **Reactive composables** for model ticks, model views, node views, and path access
- **Typed path composables** for object/array/string nodes
- **Render-prop components** (`UseModel`, `UseNode`) for declarative subscriptions

Reads are reactive; **writes are plain json-joy verbs** (`obj.set`, `str.ins`,
`arr.push`, …) — the binding adds no write abstraction, so you keep the full,
precise CRDT API (`set` vs `merge`, character-level text ops, etc.).

## Quick start

```vue
<script setup lang="ts">
import {Model} from 'json-joy/lib/json-crdt';
import {provideModel, useModelView, useStr} from '@jsonjoy.com/collaborative-vue';

const model = Model.create({title: 'Hello'});
provideModel(model); // share via context (optional)

const root = useModelView(model); // ShallowRef<view> — reactive snapshot
const title = useStr(['title'], model.api); // ShallowRef<StrApi | undefined>

const appendBang = () => title.value?.ins(title.value.view().length, '!');
</script>

<template>
<p>{{ root.title }}</p>
<button @click="appendBang">Append !</button>
</template>
```

## Context API

A single context carries a `CrdtNodeApi`; the `Model` is derived from
`node.api.model` (so providing a model just provides its root `api`).

### Provide

- `provideModel(model)` / `provideNode(node)` — call inside `setup()`
- `<ModelProvider :model="model">…</ModelProvider>` / `<NodeProvider :node="node">…</NodeProvider>`

### Read

- `useCtxModel()` / `useCtxNode()` — optional access (`undefined` if none provided)
- `useCtxModelStrict()` / `useCtxNodeStrict()` — strict access (throws `NO_NODE`)

### Isolated context

`createNodeCtx()` returns a fresh injection key plus its own `provide*` / `useCtx*`
bindings, when you need a second independent context in the same tree.

## Composables

Every composable returns a Vue ref (read `.value`, or use directly in templates).
All accept an explicit `model`/`node`, or fall back to the context node. Node and
path composables take an `event` granularity: `'self'`, `'child'`, or `'subtree'`
(default). Subscriptions are torn down automatically on scope dispose.

### Model

- `useModelTick(model?)` → `Ref<number>` — re-renders on every model change
- `useModelView(model?)` → `Ref<view>` — re-renders only when the view identity changes
- `useModel(selector, model?)` → `ComputedRef<R>` — derive a value, recomputed per tick
- `useModelTry(selector, model?)` → `ComputedRef<R | undefined>` — safe variant

### Node

- `useNodeEvents(event, listener, node?)` → unsubscribe fn (manual lifecycle)
- `useNodeEffect(event, listener, node?)` → `void` — auto-unsubscribes on dispose
- `useNodeChange(event, node?)` → `Ref<ChangeEvent | undefined>`
- `useNode(node?, event?)` → `Ref<node>` — re-triggers on every matching change
- `useNodeView(node?, event?)` → `Ref<view>`

### Path

- `usePath(path, node?, event?)` → `Ref<CrdtNodeApi | undefined>`
- `usePathView(path, node?, event?)` → `Ref<unknown>`
- `useObj(path?, node?, event?)` → `Ref<ObjApi | undefined>`
- `useArr(path?, node?, event?)` → `Ref<ArrApi | undefined>`
- `useStr(path?, node?, event?)` → `Ref<StrApi | undefined>`

## Components

### `UseModel`

Re-renders its default slot on model change; the slot receives the model.

```vue
<UseModel :model="model" v-slot="{ model }">
<pre>{{ JSON.stringify(model.api.view(), null, 2) }}</pre>
</UseModel>
```

### `UseNode`

Re-renders its default slot on the given node event; the slot receives the node.

```vue
<UseNode :node="model.s.$" event="subtree" v-slot="{ node }">
<pre>{{ JSON.stringify(node.view(), null, 2) }}</pre>
</UseNode>
```

## High-level `collaborate()` (optional)

For plain-object ergonomics and `v-model` two-way binding, `collaborate(model)`
returns a single fine-grained reactive `state` proxy you read and mutate directly.
It is **sugar on top of the composables** — the same per-node `onNodeChange('self')`
subscription for reactivity, writes dispatched through the existing node verbs.
Drop down to the composables when you need precise control (in-place text ops,
`merge` vs `set`, explicit event granularity).

```vue
<script setup lang="ts">
import {Model} from 'json-joy/lib/json-crdt';
import {collaborate} from '@jsonjoy.com/collaborative-vue';

const model = Model.create({title: 'Untitled', notes: []});
const {state} = collaborate<Board>(model);

const addNote = () => state.notes.push({by: me, text: '', at: Date.now()});
</script>

<template>
<input v-model="state.title" /> <!-- two-way bound to the CRDT -->
<li v-for="(note, i) in state.notes" :key="i">
<input v-model="note.text" /> <!-- deep, in place -->
</li>
</template>
```

- `collaborate<T>(model): { state: T; dispose(): void }`
- `dispose()` runs automatically when called inside a component `setup()`.
- Reads subscribe per node; writes record CRDT ops; remote `applyPatch` re-renders
only the nodes that changed.

## Notes

- Reactivity granularity follows json-joy's node events. `'subtree'` re-renders on
any descendant change; narrow it to `'self'`/`'child'` to re-render less.
- `useModelView` only re-renders when the view *identity* changes (json-joy
preserves the view object across structurally-identical updates); node
composables re-render on every matching change event.
- Model-level subscriptions (`useModelTick`/`useModelView`) buffer on a microtask;
node-level subscriptions fire synchronously.
- Transport is your choice: publish local ops via `model.api.onLocalChange` /
`model.api.flush()` and feed remote patches to `model.applyPatch(...)`.

## License

Apache-2.0
13 changes: 13 additions & 0 deletions packages/collaborative-vue/SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Security Policy

## Supported Versions

We release patches for security vulnerabilities. The latest major version
will support security patches.

## Reporting a Vulnerability

Please report (suspected) security vulnerabilities to
**[streamich@gmail.com](mailto:streamich@gmail.com)**. We will try to respond
within 48 hours. If the issue is confirmed, we will release a patch as soon
as possible depending on complexity.
67 changes: 67 additions & 0 deletions packages/collaborative-vue/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
{
"name": "@jsonjoy.com/collaborative-vue",
"version": "18.27.0",
"description": "JSON CRDT Vue.js composables, components, and context helpers — the Vue counterpart of @jsonjoy.com/collaborative-react.",
"author": {
"name": "streamich",
"url": "https://github.com/streamich"
},
"license": "Apache-2.0",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"files": [
"lib/"
],
"keywords": [
"collaborative",
"multiplayer",
"binding",
"collaborative-vue",
"composables",
"vue",
"json-crdt",
"json",
"crdt",
"local-first"
],
"dependencies": {
"json-joy": "workspace:*"
},
"peerDependencies": {
"tslib": "*",
"vue": "^3.4"
},
"devDependencies": {
"jest-environment-jsdom": "^30",
"vue": "^3.5.13"
},
"scripts": {
"clean": "npx rimraf lib typedocs coverage gh-pages yarn-error.log",
"build": "tsc -b tsconfig.build.json",
"jest": "node -r ts-node/register ./node_modules/.bin/jest",
"test": "jest --maxWorkers 7",
"test:ci": "yarn jest --maxWorkers 3 --no-cache",
"typecheck": "tsc -b --noEmit"
},
"jest": {
"preset": "ts-jest",
"testEnvironment": "node",
"moduleFileExtensions": [
"ts",
"js",
"tsx"
],
"transform": {
"^.+\\.tsx?$": "ts-jest"
},
"transformIgnorePatterns": [
".*/node_modules/.*"
],
"testRegex": ".*/(__tests__|__jest__)/.*(?<!\\.vi)\\.(test|spec)\\.tsx?$",
"rootDir": ".",
"testPathIgnorePatterns": [
"node_modules",
"\\.vi\\.(test|spec)\\.tsx?$"
]
}
}
122 changes: 122 additions & 0 deletions packages/collaborative-vue/src/__tests__/collaborate.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import {nextTick, watchEffect} from 'vue';
import {Model, s} from 'json-joy/lib/json-crdt';
import {collaborate} from '..';

interface Note {
by: string;
text: string;
at: number;
}
interface Board {
title: string;
notes: Note[];
}

const newModel = () =>
Model.create(
s.obj({
title: s.str('Untitled'),
notes: s.arr<any>([]),
}),
) as unknown as Model<any>;

describe('collaborate()', () => {
test('reads mirror the document view', () => {
const model = newModel();
const {state, dispose} = collaborate<Board>(model);
expect(state.title).toBe('Untitled');
expect(state.notes.length).toBe(0);
dispose();
});

test('plain assignment and array methods record CRDT ops', () => {
const model = newModel();
const {state, dispose} = collaborate<Board>(model);
state.title = 'Hello';
state.notes.push({by: 'a', text: 'first', at: 1});
state.notes[0].text = 'edited';
expect(model.view()).toEqual({title: 'Hello', notes: [{by: 'a', text: 'edited', at: 1}]});
dispose();
});

test('is fine-grained: editing one field does not re-run unrelated effects', () => {
const model = newModel();
const {state, dispose} = collaborate<Board>(model);

let titleRuns = 0;
let notesRuns = 0;
const stopTitle = watchEffect(
() => {
void state.title;
titleRuns++;
},
{flush: 'sync'},
);
const stopNotes = watchEffect(
() => {
void state.notes.length;
notesRuns++;
},
{flush: 'sync'},
);
expect(titleRuns).toBe(1);
expect(notesRuns).toBe(1);

state.title = 'Changed';
expect(titleRuns).toBe(2);
expect(notesRuns).toBe(1);

state.notes.push({by: 'a', text: 'n', at: 1});
expect(notesRuns).toBe(2);
expect(titleRuns).toBe(2);

stopTitle();
stopNotes();
dispose();
});

test('applies remote patches and re-renders the affected node', async () => {
const local = newModel();
local.api.flush();
const remote = local.fork();

const {state, dispose} = collaborate<Board>(local);
let titleRuns = 0;
const stop = watchEffect(
() => {
void state.title;
titleRuns++;
},
{flush: 'sync'},
);
expect(titleRuns).toBe(1);

remote.api.obj([]).set({title: 'From remote'});
const patch = remote.api.flush();
local.applyPatch(patch);
await nextTick();

expect(state.title).toBe('From remote');
expect(titleRuns).toBe(2);

stop();
dispose();
});

test('dispose() stops reacting to further changes', () => {
const model = newModel();
const {state, dispose} = collaborate<Board>(model);
let runs = 0;
const stop = watchEffect(
() => {
void state.title;
runs++;
},
{flush: 'sync'},
);
dispose();
model.api.obj([]).set({title: 'after dispose'});
expect(runs).toBe(1);
stop();
});
});
Loading