Vue

Wrap a pulse with a small composable. Templates read it, bind it with v-model and call its array methods, and remote changes re-render them.

The definition

note.ts
import {
  array, createWebSocketConnector, definePulse, object,
  type ModelFromDefinition, type Pulse,
} from "pulsify-core";

export const Note = definePulse("Note", object({
  title: "string",
  tags: array("string"),
  author: object({ name: "string" }),
})).materialize();
export type NotePulse = Pulse<ModelFromDefinition<typeof Note>>;

export const remote = createWebSocketConnector("wss://sync.example.com/sync");

The composable

usePulse.ts
import { onScopeDispose, shallowRef } from "vue";
import { subscribePulse, type Pulse } from "pulsify-core";

/** A view of the pulse whose reads Vue tracks. Writes go to the pulse. */
export function usePulse<P extends Pulse<any>>(pulse: P): P {
  const version = shallowRef(0);
  onScopeDispose(subscribePulse(pulse, () => version.value++));
  const views = new WeakMap<object, object>();
  const view = (value: object): object => {
    let proxy = views.get(value);
    if (!proxy) {
      proxy = new Proxy(value, {
        get(target, key) {
          if (key === "__v_skip") return true; // never wrapped by reactive()
          void version.value;
          const item = Reflect.get(target, key);
          return typeof item === "object" && item !== null && !ArrayBuffer.isView(item)
            ? view(item)
            : item;
        },
      });
      views.set(value, proxy);
    }
    return proxy;
  };
  return view(pulse) as P;
}

Every component that reads the pulse, directly or through an object it received as a prop, re-renders after a change of that pulse. Vue then patches only the DOM that changed. Split very large documents into sub-pulses.

Render and edit

Editor.vue
<script setup lang="ts">
import { usePulse } from "./usePulse";
import type { NotePulse } from "./note";

const props = defineProps<{ pulse: NotePulse }>();
const note = usePulse(props.pulse);
</script>

<template>
  <input v-model="note.title" />
  <p>by {{ note.author.name }}</p>
  <ul>
    <li v-for="(tag, i) in note.tags" :key="i">{{ tag }}</li>
  </ul>
  <button @click="note.tags.push('new')">Add tag</button>
</template>

Load on the client

NotePage.vue
<script setup lang="ts">
import { onMounted, onUnmounted, shallowRef } from "vue";
import { disconnectPulse, loadPulse } from "pulsify-core";
import Editor from "./Editor.vue";
import { Note, remote, type NotePulse } from "./note";

const props = defineProps<{ id: string }>();
// shallowRef: Vue must not wrap the pulse in its own reactive proxy.
const pulse = shallowRef<NotePulse>();

// onMounted never runs during server rendering.
onMounted(async () => {
  pulse.value = await loadPulse(Note, props.id, { connectors: [remote] });
});
onUnmounted(() => pulse.value && disconnectPulse(pulse.value));
</script>

<template>
  <Editor v-if="pulse" :key="id" :pulse="pulse" />
  <p v-else>Loading…</p>
</template>
pages/notes/[id].vue
<!-- Nuxt: or skip server rendering for the whole subtree -->
<ClientOnly fallback="Loading…">
  <NotePage :id="id" />
</ClientOnly>