Skip to content

Conversation

@maxstevens-nl
Copy link
Collaborator

@maxstevens-nl maxstevens-nl commented Sep 9, 2025

This PR adds support for Synced Queries in a backwards compatible way. This is done by introducing a new createZero composable, which manages the lifecycle of a zero instance, and returns useZero and useQuery composables.

To maintain backwards compatibility, useQuery is still exported but marked deprecated.

Copy link
Collaborator

@arv arv left a comment

Choose a reason for hiding this comment

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

LGTM

@maxstevens-nl
Copy link
Collaborator Author

@danielroe good to merge?

@danielroe
Copy link
Owner

apologies for the delay. I want to review with an eye to how we would implement in nuxt (where provide/inject is often not the best pattern).

ideally something that would support lazy creation of the stateful singleton (ie on first access)

@Gerbuuun
Copy link
Collaborator

I'm doing this. A global variable and checking if it exist on each access. I watch the user session but most of the time changing the value comes with navigation/reload so not sure how necessary it is.

import { Zero } from "@rocicorp/zero";
import { schema, clientMutators } from "~~/zero";
import type { AuthData } from "~~/zero";
import { decodeJwt } from "jose";

let client: Zero<typeof schema, ReturnType<typeof clientMutators>>;

export function useZero() {
  const { session } = useUserSession();
  const config = useRuntimeConfig().public.zero;
  const decodedJWT = computed<AuthData | undefined>(() => session.value?.token ? decodeJwt<AuthData>(session.value.token) : undefined);
  const userID = computed(() => decodedJWT.value?.sub ?? 'anon');

  watch([userID], () => {
    if (client && client.userID === userID.value)
      return;
      
    if (client && !client.closed) {
      client.close();
    }

    client = new Zero({
      userID: userID.value,
      auth: () => session.value?.token,
      mutators: clientMutators(decodedJWT.value),
      server: import.meta.client ? config.server : undefined,
      schema,
    });
  }, { immediate: true });

  return client;
}

@maxstevens-nl
Copy link
Collaborator Author

@danielroe would doing something along the lines of what Pinia does:

export let activeZero: Zero<any, any> | undefined

export function setActiveZero(zero: Zero<any, any>) {
  activeZero = zero
}

export function useZero() {
  return ((hasInjectionContext() && inject(zeroSymbol)) || shallowRef(activeZero))
}

in combination with the plugin proposed in this PR and a to-be-created Nuxt module be a step in the right direction?

While not lazy-creation, it would make sure that Zero is available during SSR. You also keep the benefit of useZero returning a shallowRef (in an injection context).

Although I need to add that I don't have a lot of experience with Nuxt, so if you know of any libraries that have a more 'nuxt-native' implementation that would be very welcome.

@maxstevens-nl
Copy link
Collaborator Author

@danielroe could you put in a review?

@maxstevens-nl maxstevens-nl reopened this Oct 14, 2025
@socket-security
Copy link

socket-security bot commented Oct 14, 2025

All alerts resolved. Learn more about Socket for GitHub.

This PR previously contained dependency changes with security issues that have been resolved, removed, or ignored.

View full report

@Gerbuuun
Copy link
Collaborator

The problem I have when looking at this solution is that I cannot run multiple zero sessions side by side (something I'm doing atm). Can you think of a way that provides the instance a different way? Right now that won't work

What I tried for example:

export function createZero() {
  let zero;

  function useZero() {
    // init & watch options etc...
    return zero
  }

  function useQuery() {
    // wrap the useQuery composable and pass the zero instance
  }

  return {
    useZero,
    useQuery,
  }
}

And then the user can create their own instance composable:

export { useZero, useQuery } = createZero<Schema, Mutators>()

Not sure about the UX though

@maxstevens-nl
Copy link
Collaborator Author

export { useZero, useQuery } = createZero<Schema, Mutators>()

@Gerbuuun This is very interesting, seems like the way to go.

Not sure about the UX though

I think this would actually be better, since this PR already introduces the createUseZero composable combined with a plugin. The plugin can be dropped in this approach.

I'm not sure how well this can be adapted to work in Nuxt, @Gerbuuun are you able to weigh in on that as well?

Copy link
Collaborator

Choose a reason for hiding this comment

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

This createZero function is now a composable. This means it does not work out of the vue context. z is not shared and every time you need to call createZero.

What I meant with my example is to call createZero top-level which then creates the composables useZero and useQuery. That way each time you call the imported composable useZero, you get the already existing instance.

I haven't fully worked it out from there because of reactivity problems. I'll clean up my code and share the branch

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

What I meant with my example is to call createZero top-level which then creates the composables useZero and useQuery

These changes achieve exactly that right? The concept is that you call createZero once for each zero instance/session you have in your application. The useZero and useQuery composables can then be exposed to the rest of the application.

This createZero function is now a composable. This means it does not work out of the vue context.

Could you elaborate what doesn't work outside of the vue context?

@Gerbuuun
Copy link
Collaborator

I have created a draft PR #137 with my idea and a working playground so you can check it out @maxstevens-nl
I also made some internal changes to Views and added error forwarding but those are not important to understand what I mean.

Please let me know what you think.

@maxstevens-nl
Copy link
Collaborator Author

I have created a draft PR #137 with my idea and a working playground so you can check it out @maxstevens-nl I also made some internal changes to Views and added error forwarding but those are not important to understand what I mean.

Please let me know what you think.

@Gerbuuun left some comments on that PR. I changed this PR to add lazy init like you did in #137, and updated the playground with the correct usage. Does this PR now reflect what you had in mind?

@maxstevens-nl maxstevens-nl mentioned this pull request Oct 18, 2025
Comment on lines 10 to 29
export function createZero<S extends Schema = Schema, MD extends CustomMutatorDefs | undefined = undefined>(optsOrZero: MaybeRefOrGetter<ZeroOptions<S, MD> | { zero: Zero<S, MD> }>) {
let z: ShallowRef<Zero<S, MD>>

function useZero(): ShallowRef<Zero<S, MD>> {
if (!z) {
z = shallowRef() as ShallowRef<Zero<S, MD>>
}

if (z.value) {
return z
}

watch(() => toValue(optsOrZero), (opts) => {
if (z.value && !z.value.closed) {
const cleanupZeroPromise = z.value.close()
zeroCleanups.add(cleanupZeroPromise)
cleanupZeroPromise.finally(() => {
zeroCleanups.delete(cleanupZeroPromise)
})
}

z.value = 'zero' in opts ? opts.zero : new Zero(opts)
}, { deep: true, immediate: true })

return z
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is my final suggestion. This is basically writing a wrapped composable that is only called on init. Also, previously watching the options directly, people could use composables inside the getter function. If they register watchers, these would be added each time the getter is called. This way we don't have that problem.

Suggested change
export function createZero<S extends Schema = Schema, MD extends CustomMutatorDefs | undefined = undefined>(optsOrZero: MaybeRefOrGetter<ZeroOptions<S, MD> | { zero: Zero<S, MD> }>) {
let z: ShallowRef<Zero<S, MD>>
function useZero(): ShallowRef<Zero<S, MD>> {
if (!z) {
z = shallowRef() as ShallowRef<Zero<S, MD>>
}
if (z.value) {
return z
}
watch(() => toValue(optsOrZero), (opts) => {
if (z.value && !z.value.closed) {
const cleanupZeroPromise = z.value.close()
zeroCleanups.add(cleanupZeroPromise)
cleanupZeroPromise.finally(() => {
zeroCleanups.delete(cleanupZeroPromise)
})
}
z.value = 'zero' in opts ? opts.zero : new Zero(opts)
}, { deep: true, immediate: true })
return z
}
export function createZero<S extends Schema, MD extends CustomMutatorDefs | undefined = undefined>(opts: () => MaybeRefOrGetter<ZeroOptions<S, MD>>) {
let z: ShallowRef<Zero<S, MD>>
function useZero() {
if (!z) {
z = shallowRef(undefined!)
}
if (z.value) {
return z.value
}
const options = opts()
watch(() => toValue(options), (opts) => {
if (z.value && !z.value.closed) {
const cleanupZeroPromise = z.value.close()
zeroCleanups.add(cleanupZeroPromise)
cleanupZeroPromise.finally(() => {
zeroCleanups.delete(cleanupZeroPromise)
})
}
z.value = new Zero(opts)
}, { deep: true, immediate: true })
return z.value
}

It feels a bit over engineered at this point though...

I'm also not quite sure what the cleanupPromise code is for?
Can't you just call:

z.value?.close();

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yeah you can just call z.value.close(). The intent was to prevent memory leaks, but it turns out it's fine to clean up like you say. I changed it back to the simpler variant.

I'm not sure about the other changes you propose here. Would say it's the responsibility of the caller to make sure they cleanup watchers? Using MaybeRefOrGetter as type for the props is a common pattern in libraries, used all over VueUse for example. I would say keeping it that way is preferable.

Regarding the props not being reactive like you mentioned in the other PR, I tried to reproduce it in a test but couldn't get it to break. Could you provide an example of a test for this that doesn't pass? The following does pass for example:

const session = ref<{ id: string | null }>({ id: null })
function useSession(id: MaybeRefOrGetter<string>) {
  async function fetch() {
    session.value.id = toValue(id)
  }

  async function clear() {
    session.value.id = null
  }

  return {
    session,
    fetch,
    clear,
  }
}

it('works with options getter', async () => {
  const { useZero } = createZero(() => {
    const { session } = useSession('test-user')

    const zeroOptions = {
      userID: session.value?.id ?? 'anon',
      server: null,
      schema: testSchema,
      kvStore: 'mem' as const,
    }

    return zeroOptions
  })

  const { fetch, clear } = useSession(() => 'test-user')
  await fetch()

  const zero = useZero()
  assert(zero.value)
  expect(zero.value.userID).toEqual('test-user')

  await clear()
  expect(zero.value.userID).toEqual('anon')

  await fetch()
  expect(zero.value.userID).toEqual('test-user')
})

@maxstevens-nl
Copy link
Collaborator Author

@danielroe in my opinion this is ready now, do you want to take a look?

NB I also have a PR for a useZeroOnline composable lined up, which relies on this one. We might be able to combine that into one release.

@maxstevens-nl maxstevens-nl requested review from danielroe and removed request for danielroe October 24, 2025 07:34
import { shallowRef, toValue, watch } from 'vue'
import { useQueryWithZero } from './query'

export function createZero<S extends Schema = Schema, MD extends CustomMutatorDefs | undefined = undefined>(optsOrZero: MaybeRefOrGetter<ZeroOptions<S, MD> | { zero: Zero<S, MD> }>) {
Copy link
Owner

Choose a reason for hiding this comment

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

I think this should be something like: createSyncedZeroComposables (or a better name?) as semantically it does not create a zero instance.

Another approach would be, allowing passing a zero shallowRef as a first argument to useQuery, and then creating a 'bound' version like this:

const zero = createZero() // the same utility as `createZero` here, which lazily initialises zero on access
const useQuery = _useQuery.bind(zero)
// or
nuxtApp.provide('zero', zero)
function useQuery (opts) {
  const { $zero } = useNuxtApp()
  return _useQuery($zero, opts)
}
// or any number of other patterns

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Agreed regarding the name, will change.

I'll also export useQueryWithZero and swap the params so that zero will be first. This will allow a user to create a bound version of useQuery if they need the extra control over the Zero instance.

Copy link
Owner

@danielroe danielroe left a comment

Choose a reason for hiding this comment

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

thank you for your good work on this, both of you ❤️

and my apologies for slow reviewing 🙏

@danielroe danielroe merged commit fd078a7 into danielroe:main Oct 24, 2025
7 checks passed
@miguelrk
Copy link

miguelrk commented Oct 29, 2025

Huge thanks for this PR! @danielroe Is there something else blocking a release? Or could we get this released? 🙏🏼

@danielroe
Copy link
Owner

we could indeed!

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.

5 participants