Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(react): Allow passing a server snapshot to useQuery. #3686

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
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
95 changes: 80 additions & 15 deletions packages/zero-react/src/use-query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,18 @@ describe('ViewStore', () => {
test('duplicate queries do not create duplicate views', () => {
const viewStore = new ViewStore();

const view1 = viewStore.getView('client1', newMockQuery('query1'), true);
const view2 = viewStore.getView('client1', newMockQuery('query1'), true);
const view1 = viewStore.getView(
'client1',
newMockQuery('query1'),
true,
false,
);
const view2 = viewStore.getView(
'client1',
newMockQuery('query1'),
true,
false,
);

expect(view1).toBe(view2);

Expand All @@ -52,8 +62,18 @@ describe('ViewStore', () => {
test('removing a duplicate query does not destroy the shared view', () => {
const viewStore = new ViewStore();

const view1 = viewStore.getView('client1', newMockQuery('query1'), true);
const view2 = viewStore.getView('client1', newMockQuery('query1'), true);
const view1 = viewStore.getView(
'client1',
newMockQuery('query1'),
true,
false,
);
const view2 = viewStore.getView(
'client1',
newMockQuery('query1'),
true,
false,
);

const cleanup1 = view1.subscribeReactInternals(() => {});
view2.subscribeReactInternals(() => {});
Expand All @@ -70,8 +90,18 @@ describe('ViewStore', () => {
test('removing all duplicate queries destroys the shared view', () => {
const viewStore = new ViewStore();

const view1 = viewStore.getView('client1', newMockQuery('query1'), true);
const view2 = viewStore.getView('client1', newMockQuery('query1'), true);
const view1 = viewStore.getView(
'client1',
newMockQuery('query1'),
true,
false,
);
const view2 = viewStore.getView(
'client1',
newMockQuery('query1'),
true,
false,
);

const cleanup1 = view1.subscribeReactInternals(() => {});
const cleanup2 = view2.subscribeReactInternals(() => {});
Expand All @@ -87,7 +117,12 @@ describe('ViewStore', () => {
test('removing a unique query destroys the view', () => {
const viewStore = new ViewStore();

const view = viewStore.getView('client1', newMockQuery('query1'), true);
const view = viewStore.getView(
'client1',
newMockQuery('query1'),
true,
false,
);

const cleanup = view.subscribeReactInternals(() => {});
cleanup();
Expand All @@ -99,7 +134,12 @@ describe('ViewStore', () => {
test('view destruction is delayed via setTimeout', () => {
const viewStore = new ViewStore();

const view = viewStore.getView('client1', newMockQuery('query1'), true);
const view = viewStore.getView(
'client1',
newMockQuery('query1'),
true,
false,
);

const cleanup = view.subscribeReactInternals(() => {});
cleanup();
Expand All @@ -113,7 +153,12 @@ describe('ViewStore', () => {

test('subscribing to a view scheduled for cleanup prevents the cleanup', () => {
const viewStore = new ViewStore();
const view = viewStore.getView('client1', newMockQuery('query1'), true);
const view = viewStore.getView(
'client1',
newMockQuery('query1'),
true,
false,
);
const cleanup = view.subscribeReactInternals(() => {});

cleanup();
Expand All @@ -122,7 +167,12 @@ describe('ViewStore', () => {
vi.advanceTimersByTime(5);
expect(getAllViewsSizeForTesting(viewStore)).toBe(1);

const view2 = viewStore.getView('client1', newMockQuery('query1'), true);
const view2 = viewStore.getView(
'client1',
newMockQuery('query1'),
true,
false,
);
const cleanup2 = view.subscribeReactInternals(() => {});
vi.advanceTimersByTime(100);

Expand All @@ -137,7 +187,12 @@ describe('ViewStore', () => {

test('destroying the same underlying view twice is a no-op', () => {
const viewStore = new ViewStore();
const view = viewStore.getView('client1', newMockQuery('query1'), true);
const view = viewStore.getView(
'client1',
newMockQuery('query1'),
true,
false,
);
const cleanup = view.subscribeReactInternals(() => {});

cleanup();
Expand All @@ -152,8 +207,18 @@ describe('ViewStore', () => {
test('the same query for different clients results in different views', () => {
const viewStore = new ViewStore();

const view1 = viewStore.getView('client1', newMockQuery('query1'), true);
const view2 = viewStore.getView('client2', newMockQuery('query1'), true);
const view1 = viewStore.getView(
'client1',
newMockQuery('query1'),
true,
false,
);
const view2 = viewStore.getView(
'client2',
newMockQuery('query1'),
true,
false,
);

expect(view1).not.toBe(view2);
});
Expand All @@ -166,7 +231,7 @@ describe('ViewStore', () => {
const {listeners} = q.materialize() as unknown as {
listeners: Set<(data: unknown, resultType: ResultType) => void>;
};
const view = viewStore.getView('client1', q, true);
const view = viewStore.getView('client1', q, true, false);

const cleanup = view.subscribeReactInternals(() => {});

Expand Down Expand Up @@ -202,7 +267,7 @@ describe('ViewStore', () => {
const {listeners} = q.materialize() as unknown as {
listeners: Set<(...args: unknown[]) => void>;
};
const view = viewStore.getView('client1', q, true);
const view = viewStore.getView('client1', q, true, false);

const cleanup = view.subscribeReactInternals(() => {});

Expand Down
47 changes: 44 additions & 3 deletions packages/zero-react/src/use-query.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {useSyncExternalStore} from 'react';
import {useMemo, useSyncExternalStore} from 'react';
import {deepClone} from '../../shared/src/deep-clone.ts';
import type {Immutable} from '../../shared/src/immutable.ts';
import type {ReadonlyJSONValue} from '../../shared/src/json.ts';
Expand All @@ -23,19 +23,41 @@ export function useQuery<
TReturn,
>(
q: Query<TSchema, TTable, TReturn>,
enable: boolean = true,
enableOrOptions:
| boolean
| {
enable?: boolean | undefined;
serverSnapshot: TReturn | undefined;
}
| undefined = true,
): QueryResult<TReturn> {
const z = useZero();
const enable =
typeof enableOrOptions === 'boolean'
? enableOrOptions
: enableOrOptions.enable ?? false;
const options =
typeof enableOrOptions === 'boolean' ? undefined : enableOrOptions;
const serverSnapshot = options?.serverSnapshot;
const view = viewStore.getView(
z.clientID,
q as AdvancedQuery<TSchema, TTable, TReturn>,
enable && z.server !== null,
serverSnapshot !== undefined,
);
const ss = useMemo(
() =>
[
serverSnapshot as unknown as HumanReadable<TReturn>,
{type: 'complete'},
] as const,
[serverSnapshot],
);
// https://react.dev/reference/react/useSyncExternalStore
return useSyncExternalStore(
view.subscribeReactInternals,
view.getSnapshot,
view.getSnapshot,
serverSnapshot ? () => ss : undefined,
);
}

Expand Down Expand Up @@ -164,6 +186,7 @@ export class ViewStore {
clientID: string,
query: AdvancedQuery<TSchema, TTable, TReturn>,
enabled: boolean,
requireComplete: boolean,
Copy link
Contributor

Choose a reason for hiding this comment

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

switch to an options object now that we're at 2 boolean args?

): {
getSnapshot: () => QueryResult<TReturn>;
subscribeReactInternals: (internals: () => void) => () => void;
Expand Down Expand Up @@ -193,6 +216,7 @@ export class ViewStore {
() => {
this.#views.delete(hash);
},
requireComplete,
) as ViewWrapper<TSchema, TTable, TReturn>;
this.#views.set(hash, existing);
}
Expand Down Expand Up @@ -238,24 +262,41 @@ class ViewWrapper<
readonly #query: AdvancedQuery<TSchema, TTable, TReturn>;
#snapshot: QueryResult<TReturn>;
#reactInternals: Set<() => void>;
#requireComplete: boolean;

constructor(
query: AdvancedQuery<TSchema, TTable, TReturn>,
onMaterialized: (view: ViewWrapper<TSchema, TTable, TReturn>) => void,
onDematerialized: () => void,
requireComplete: boolean,
) {
this.#snapshot = getDefaultSnapshot(query.format.singular);
this.#onMaterialized = onMaterialized;
this.#onDematerialized = onDematerialized;
this.#reactInternals = new Set();
this.#query = query;
this.#requireComplete = requireComplete;
this.#materializeIfNeeded();
}

#onData = (
snap: Immutable<HumanReadable<TReturn>>,
resultType: ResultType,
) => {
// We allow waiting for first compelete result in the case where there's a
// server snapshot. We don't want to bounce back to an older result from
// local store if there's a server snapshot.
//
// Note: when we have consistency, we can have a local complete result that
// is still behind network. So in this case we'd have to add a new 'source'
// or something that means whether it came from the server.
if (this.#requireComplete && resultType !== 'complete') {
return;
}
// Once the first complete result is received, we no longer want to ignore
// other result types. Queries can bounce back to unknown for certain
// changes and we want caller to receive those updates.
this.#requireComplete = false;
const data =
snap === undefined
? snap
Expand Down