diff --git a/packages/vue-db/package.json b/packages/vue-db/package.json index acd8bcae..0326cfbb 100644 --- a/packages/vue-db/package.json +++ b/packages/vue-db/package.json @@ -17,13 +17,12 @@ ], "packageManager": "pnpm@10.5.2", "dependencies": { - "@tanstack/db": "workspace:*", - "@tanstack/vue-store": "^0.7.0" + "@tanstack/db": "workspace:*" }, "devDependencies": { "@electric-sql/client": "1.0.0", - "@vitest/coverage-istanbul": "^3.0.9", "@vitejs/plugin-vue": "^5.2.4", + "@vitest/coverage-istanbul": "^3.0.9", "vue": "^3.5.13" }, "exports": { diff --git a/packages/vue-db/src/useLiveQuery.ts b/packages/vue-db/src/useLiveQuery.ts index 0fea1476..5cd8df96 100644 --- a/packages/vue-db/src/useLiveQuery.ts +++ b/packages/vue-db/src/useLiveQuery.ts @@ -1,6 +1,6 @@ -import { computed, toValue, watch } from "vue" -import { useStore } from "@tanstack/vue-store" +import { computed, onScopeDispose, shallowRef } from "vue" import { compileQuery, queryBuilder } from "@tanstack/db" +import { shallow } from "./useStore" import type { Collection, Context, @@ -9,12 +9,11 @@ import type { ResultsFromContext, Schema, } from "@tanstack/db" -import type { ComputedRef, MaybeRefOrGetter } from "vue" export interface UseLiveQueryReturn { - state: ComputedRef> - data: ComputedRef> - collection: ComputedRef> + state: () => Readonly> + data: () => Readonly> + collection: () => Collection } export function useLiveQuery< @@ -22,39 +21,63 @@ export function useLiveQuery< >( queryFn: ( q: InitialQueryBuilder> - ) => QueryBuilder, - deps: Array> = [] + ) => QueryBuilder ): UseLiveQueryReturn> { - const compiledQuery = computed(() => { - // Just reference deps to make computed reactive to them - deps.forEach((dep) => toValue(dep)) - - const query = queryFn(queryBuilder()) - const compiled = compileQuery(query) - compiled.start() - return compiled - }) + const NOOP = () => {} + let unsubCompiled = NOOP + let unsubDerivedState = NOOP + let unsubDerivedArray = NOOP + + const compiled = computed>>( + () => { + unsubCompiled() + const compiledRef = compileQuery(queryFn(queryBuilder())) + unsubCompiled = compiledRef.start() + return compiledRef + } + ) const state = computed(() => { - return useStore(compiledQuery.value.results.derivedState).value + const derivedState = compiled.value.results.derivedState + let stateRef = derivedState.state + const ret = shallowRef(stateRef) + + unsubDerivedState() + unsubDerivedState = derivedState.subscribe(() => { + const newValue = derivedState.state + if (shallow(stateRef, newValue)) return + + stateRef = newValue + ret.value = newValue + }) + return ret }) + const data = computed(() => { - return useStore(compiledQuery.value.results.derivedArray).value - }) + const derivedArray = compiled.value.results.derivedArray + let stateRef = derivedArray.state + const ret = shallowRef(stateRef) - watch(compiledQuery, (newQuery, oldQuery, onInvalidate) => { - if (newQuery.state === `stopped`) { - newQuery.start() - } + unsubDerivedArray() + unsubDerivedArray = derivedArray.subscribe(() => { + const newValue = derivedArray.state + if (shallow(stateRef, newValue)) return - onInvalidate(() => { - oldQuery.stop() + stateRef = newValue + ret.value = newValue }) + return ret + }) + + onScopeDispose(() => { + unsubCompiled() + unsubDerivedState() + unsubDerivedArray() }) return { - state, - data, - collection: computed(() => compiledQuery.value.results), + state: () => state.value.value, + data: () => data.value.value, + collection: () => compiled.value.results, } } diff --git a/packages/vue-db/src/useStore.ts b/packages/vue-db/src/useStore.ts new file mode 100644 index 00000000..15a9651c --- /dev/null +++ b/packages/vue-db/src/useStore.ts @@ -0,0 +1,49 @@ +/** + * @see https://github.com/TanStack/store/blob/cf37b85ddecdcb6f52ad930dcd53e294fb4b03a7/packages/vue-store/src/index.ts#L47 + */ + +export function shallow(objA: T, objB: T) { + if (Object.is(objA, objB)) { + return true + } + + if ( + typeof objA !== `object` || + objA === null || + typeof objB !== `object` || + objB === null + ) { + return false + } + + if (objA instanceof Map && objB instanceof Map) { + if (objA.size !== objB.size) return false + for (const [k, v] of objA) { + if (!objB.has(k) || !Object.is(v, objB.get(k))) return false + } + return true + } + + if (objA instanceof Set && objB instanceof Set) { + if (objA.size !== objB.size) return false + for (const v of objA) { + if (!objB.has(v)) return false + } + return true + } + + const keysA = Object.keys(objA) + if (keysA.length !== Object.keys(objB).length) { + return false + } + + for (const keyA of keysA) { + if ( + !Object.prototype.hasOwnProperty.call(objB, keyA) || + !Object.is(objA[keyA as keyof T], objB[keyA as keyof T]) + ) { + return false + } + } + return true +} diff --git a/packages/vue-db/tests/useLiveQuery.test.ts b/packages/vue-db/tests/useLiveQuery.test.ts index e41db5cd..d5e6eff1 100644 --- a/packages/vue-db/tests/useLiveQuery.test.ts +++ b/packages/vue-db/tests/useLiveQuery.test.ts @@ -1,9 +1,8 @@ import { describe, expect, it, vi } from "vitest" import mitt from "mitt" import { Collection, createTransaction } from "@tanstack/db" -import { computed, onUnmounted, ref, watch, watchEffect } from "vue" -import { useLiveQuery } from "../src/useLiveQuery" -import type { Ref } from "vue" +import { onWatcherCleanup, ref, watchEffect } from "vue" +import { useLiveQuery } from "../src/index" import type { Context, InitialQueryBuilder, @@ -119,15 +118,15 @@ describe(`Query Collections`, () => { .orderBy({ "@id": `asc` }) ) - expect(state.value.size).toBe(1) - expect(state.value.get(`3`)).toEqual({ + expect(state().size).toBe(1) + expect(state().get(`3`)).toEqual({ _orderByIndex: 0, id: `3`, name: `John Smith`, }) - expect(data.value.length).toBe(1) - expect(data.value[0]).toEqual({ + expect(data().length).toBe(1) + expect(data()[0]).toEqual({ _orderByIndex: 0, id: `3`, name: `John Smith`, @@ -150,25 +149,25 @@ describe(`Query Collections`, () => { await waitForChanges() - expect(state.value.size).toBe(2) - expect(state.value.get(`3`)).toEqual({ + expect(state().size).toBe(2) + expect(state().get(`3`)).toEqual({ _orderByIndex: 0, id: `3`, name: `John Smith`, }) - expect(state.value.get(`4`)).toEqual({ + expect(state().get(`4`)).toEqual({ _orderByIndex: 1, id: `4`, name: `Kyle Doe`, }) - expect(data.value.length).toBe(2) - expect(data.value).toContainEqual({ + expect(data().length).toBe(2) + expect(data()).toContainEqual({ _orderByIndex: 0, id: `3`, name: `John Smith`, }) - expect(data.value).toContainEqual({ + expect(data()).toContainEqual({ _orderByIndex: 1, id: `4`, name: `Kyle Doe`, @@ -187,15 +186,15 @@ describe(`Query Collections`, () => { await waitForChanges() - expect(state.value.size).toBe(2) - expect(state.value.get(`4`)).toEqual({ + expect(state().size).toBe(2) + expect(state().get(`4`)).toEqual({ _orderByIndex: 1, id: `4`, name: `Kyle Doe 2`, }) - expect(data.value.length).toBe(2) - expect(data.value).toContainEqual({ + expect(data().length).toBe(2) + expect(data()).toContainEqual({ _orderByIndex: 1, id: `4`, name: `Kyle Doe 2`, @@ -211,11 +210,11 @@ describe(`Query Collections`, () => { await waitForChanges() - expect(state.value.size).toBe(1) - expect(state.value.get(`4`)).toBeUndefined() + expect(state().size).toBe(1) + expect(state().get(`4`)).toBeUndefined() - expect(data.value.length).toBe(1) - expect(data.value).toContainEqual({ + expect(data().length).toBe(1) + expect(data()).toContainEqual({ _orderByIndex: 0, id: `3`, name: `John Smith`, @@ -300,21 +299,21 @@ describe(`Query Collections`, () => { await waitForChanges() // Verify that we have the expected joined results - expect(state.value.size).toBe(3) + expect(state().size).toBe(3) - expect(state.value.get(`1`)).toEqual({ + expect(state().get(`1`)).toEqual({ id: `1`, name: `John Doe`, title: `Issue 1`, }) - expect(state.value.get(`2`)).toEqual({ + expect(state().get(`2`)).toEqual({ id: `2`, name: `Jane Doe`, title: `Issue 2`, }) - expect(state.value.get(`3`)).toEqual({ + expect(state().get(`3`)).toEqual({ id: `3`, name: `John Doe`, title: `Issue 3`, @@ -336,8 +335,8 @@ describe(`Query Collections`, () => { await waitForChanges() - expect(state.value.size).toBe(4) - expect(state.value.get(`4`)).toEqual({ + expect(state().size).toBe(4) + expect(state().get(`4`)).toEqual({ id: `4`, name: `Jane Doe`, title: `Issue 4`, @@ -357,7 +356,7 @@ describe(`Query Collections`, () => { await waitForChanges() // The updated title should be reflected in the joined results - expect(state.value.get(`2`)).toEqual({ + expect(state().get(`2`)).toEqual({ id: `2`, name: `Jane Doe`, title: `Updated Issue 2`, @@ -374,7 +373,7 @@ describe(`Query Collections`, () => { await waitForChanges() // After deletion, user 3 should no longer have a joined result - expect(state.value.get(`3`)).toBeUndefined() + expect(state().get(`3`)).toBeUndefined() }) it(`should recompile query when parameters change and change results`, async () => { @@ -411,6 +410,8 @@ describe(`Query Collections`, () => { })) ) + await waitForChanges() + const minAge = ref(30) const { state } = useLiveQuery((q) => { @@ -422,8 +423,8 @@ describe(`Query Collections`, () => { }) // Initially should return only people older than 30 - expect(state.value.size).toBe(1) - expect(state.value.get(`3`)).toEqual({ + expect(state().size).toBe(1) + expect(state().get(`3`)).toEqual({ id: `3`, name: `John Smith`, age: 35, @@ -435,18 +436,18 @@ describe(`Query Collections`, () => { await waitForChanges() // Now should return all people as they're all older than 20 - expect(state.value.size).toBe(3) - expect(state.value.get(`1`)).toEqual({ + expect(state().size).toBe(3) + expect(state().get(`1`)).toEqual({ id: `1`, name: `John Doe`, age: 30, }) - expect(state.value.get(`2`)).toEqual({ + expect(state().get(`2`)).toEqual({ id: `2`, name: `Jane Doe`, age: 25, }) - expect(state.value.get(`3`)).toEqual({ + expect(state().get(`3`)).toEqual({ id: `3`, name: `John Smith`, age: 35, @@ -458,7 +459,7 @@ describe(`Query Collections`, () => { await waitForChanges() // Should now be empty - expect(state.value.size).toBe(0) + expect(state().size).toBe(0) }) it(`should stop old query when parameters change`, async () => { @@ -495,18 +496,17 @@ describe(`Query Collections`, () => { // Add a custom hook that wraps useLiveQuery to log when queries are created and stopped function useTrackedLiveQuery( queryFn: (q: InitialQueryBuilder>) => any, - deps: Array> + deps: () => Array ): T { - const result = useLiveQuery(queryFn, deps) - - watch( - () => deps.map((dep) => dep.value).join(`,`), - (updatedDeps, _, fn) => { - console.log(`Creating new query with deps`, updatedDeps) - fn(() => console.log(`Stopping query with deps`, updatedDeps)) - }, - { immediate: true } - ) + const result = useLiveQuery(queryFn) + + watchEffect(() => { + const updatedDeps = deps().join(`,`) + console.log(`Creating new query with deps`, updatedDeps) + onWatcherCleanup(() => + console.log(`Stopping query with deps`, updatedDeps) + ) + }) return result as T } @@ -529,7 +529,7 @@ describe(`Query Collections`, () => { .where(`@age`, `>`, minAge.value) .keyBy(`@id`) .select(`@id`, `@name`), - [minAge] + () => [minAge.value] ) // Initial query should be created @@ -604,15 +604,15 @@ describe(`Query Collections`, () => { // Grouped query derived from initial query const groupedResult = useLiveQuery((q) => q - .from({ queryResult: result.collection.value }) + .from({ queryResult: result.collection() }) .groupBy(`@team`) .keyBy(`@team`) .select(`@team`, { count: { COUNT: `@id` } }) ) // Verify initial grouped results - expect(groupedResult.state.value.size).toBe(1) - expect(groupedResult.state.value.get(`team1`)).toEqual({ + expect(groupedResult.state().size).toBe(1) + expect(groupedResult.state().get(`team1`)).toEqual({ team: `team1`, count: 1, }) @@ -648,12 +648,12 @@ describe(`Query Collections`, () => { await waitForChanges() // Verify the grouped results include the new team members - expect(groupedResult.state.value.size).toBe(2) - expect(groupedResult.state.value.get(`team1`)).toEqual({ + expect(groupedResult.state().size).toBe(2) + expect(groupedResult.state().get(`team1`)).toEqual({ team: `team1`, count: 2, }) - expect(groupedResult.state.value.get(`team2`)).toEqual({ + expect(groupedResult.state().get(`team2`)).toEqual({ team: `team2`, count: 1, }) @@ -747,9 +747,9 @@ describe(`Query Collections`, () => { // Track each render state watchEffect(() => { renderStates.push({ - stateSize: state.value.size, - hasTempKey: state.value.has(`temp-key`), - hasPermKey: state.value.has(`4`), + stateSize: state().size, + hasTempKey: state().has(`temp-key`), + hasPermKey: state().has(`4`), timestamp: Date.now(), }) }) @@ -757,7 +757,7 @@ describe(`Query Collections`, () => { await waitForChanges() // Verify initial state - expect(state.value.size).toBe(3) + expect(state().size).toBe(3) // Reset render states array for clarity in the remaining test renderStates.length = 0 @@ -795,8 +795,8 @@ describe(`Query Collections`, () => { ) // Verify optimistic state is immediately reflected - expect(state.value.size).toBe(4) - expect(state.value.get(`temp-key`)).toEqual({ + expect(state().size).toBe(4) + expect(state().get(`temp-key`)).toEqual({ id: `temp-key`, name: `John Doe`, title: `New Issue`, @@ -814,9 +814,9 @@ describe(`Query Collections`, () => { expect(hadFlicker).toBe(false) // Verify the temporary key is replaced by the permanent one - expect(state.value.size).toBe(4) - expect(state.value.get(`temp-key`)).toBeUndefined() - expect(state.value.get(`4`)).toEqual({ + expect(state().size).toBe(4) + expect(state().get(`temp-key`)).toBeUndefined() + expect(state().get(`4`)).toEqual({ id: `4`, name: `John Doe`, title: `New Issue`, diff --git a/packages/vue-db/tsconfig.json b/packages/vue-db/tsconfig.json index ea04dcdf..eb10b7f1 100644 --- a/packages/vue-db/tsconfig.json +++ b/packages/vue-db/tsconfig.json @@ -11,7 +11,6 @@ "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "paths": { - "@tanstack/store": ["../store/src"], "@tanstack/db": ["../db/src"] } }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e69fa5d9..c231531c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -286,9 +286,6 @@ importers: '@tanstack/db': specifier: workspace:* version: link:../db - '@tanstack/vue-store': - specifier: ^0.7.0 - version: 0.7.0(vue@3.5.13(typescript@5.8.2)) devDependencies: '@electric-sql/client': specifier: 1.0.0 @@ -1636,15 +1633,6 @@ packages: resolution: {integrity: sha512-G6l2Q4hp/Yj43UyE9APz+Fj5sdC15G2UD2xXOSdQCZ6/4gjYd2c040TLk7ObGhypbeWBYvy93Gg18nS41F6eqg==} engines: {node: '>=18'} - '@tanstack/vue-store@0.7.0': - resolution: {integrity: sha512-oLB/WuD26caR86rxLz39LvS5YdY0KIThJFEHIW/mXujC2+M/z3GxVZFJsZianAzr3tH56sZQ8kkq4NvwwsOBkQ==} - peerDependencies: - '@vue/composition-api': ^1.2.1 - vue: ^2.5.0 || ^3.0.0 - peerDependenciesMeta: - '@vue/composition-api': - optional: true - '@testing-library/dom@10.4.0': resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} engines: {node: '>=18'} @@ -4712,17 +4700,6 @@ packages: vscode-uri@3.1.0: resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} - vue-demi@0.14.10: - resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} - engines: {node: '>=12'} - hasBin: true - peerDependencies: - '@vue/composition-api': ^1.0.0-rc.1 - vue: ^3.0.0-0 || ^2.6.0 - peerDependenciesMeta: - '@vue/composition-api': - optional: true - vue-eslint-parser@9.4.3: resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==} engines: {node: ^14.17.0 || >=16.0.0} @@ -6090,12 +6067,6 @@ snapshots: - typescript - vite - '@tanstack/vue-store@0.7.0(vue@3.5.13(typescript@5.8.2))': - dependencies: - '@tanstack/store': 0.7.0 - vue: 3.5.13(typescript@5.8.2) - vue-demi: 0.14.10(vue@3.5.13(typescript@5.8.2)) - '@testing-library/dom@10.4.0': dependencies: '@babel/code-frame': 7.26.2 @@ -9462,10 +9433,6 @@ snapshots: vscode-uri@3.1.0: {} - vue-demi@0.14.10(vue@3.5.13(typescript@5.8.2)): - dependencies: - vue: 3.5.13(typescript@5.8.2) - vue-eslint-parser@9.4.3(eslint@9.22.0(jiti@2.4.2)): dependencies: debug: 4.4.0