@tanstack/react-db
Version:
React integration for @tanstack/db
321 lines (318 loc) • 13.3 kB
JavaScript
"use client";
import { useRef, useEffect, useLayoutEffect, useSyncExternalStore } from "react";
import { prepareLiveQueryValue, identifyLiveQuery, isCollection, createLiveQuerySourceIdClaims, getLiveQueryKey, getStableValueHash, UnhashableQueryIRError, deepEquals, resolveLiveQueryValue, releaseDeferredOnReclaim, createLiveQueryObserver, rejectLiveQueryInput, getSourceObjectToken, resumeDeferredLiveQueryCollections } from "@tanstack/db";
import { prepareLiveQueryValue as prepareLiveQueryValue2 } from "@tanstack/db";
import { useOptionalDbClient } from "./DbProvider.js";
import { shouldWarnInDevelopment } from "./development.js";
import { getPreparedSources, getUnboundDescriptorReporter } from "./source-id-bindings.js";
const useWindowCommitEffect = typeof window === `undefined` ? useEffect : useLayoutEffect;
const DEFAULT_GC_TIME_MS = 1;
const DEFAULT_SUSPENSE_GC_TIME_MS = 5e3;
const DERIVED_IDENTITY_SINGLE_RENDER_WARN_MS = 16;
const DERIVED_IDENTITY_RENDER_COUNT_WARN_THRESHOLD = 10;
const DERIVED_IDENTITY_TOTAL_WARN_MS = 50;
const warnedDerivedIdentityCallsites = /* @__PURE__ */ new Set();
const unpreparedQueryValue = /* @__PURE__ */ Symbol(`unpreparedQueryValue`);
const unscopedSuspenseScope = {};
const suspenseCollectionsByClient = /* @__PURE__ */ new WeakMap();
function getSourceQualifiedSuspenseKey(preparedValue, queryHash, client) {
const sourceIds = getPreparedSources(
preparedValue,
client,
`useLiveSuspenseQuery`
).map(getSourceObjectToken);
const {
query: _query,
pool: _pool,
...options
} = preparedValue?.query ? preparedValue : {};
options.autoIndex ??= `off`;
for (const key in options) {
const value = options[key];
if (value === void 0) delete options[key];
else if (value && (typeof value === `object` || typeof value === `function`))
options[key] = getSourceObjectToken(value);
}
return getStableValueHash([sourceIds, options, queryHash]);
}
function getSuspenseCollections(client) {
const scope = client ?? unscopedSuspenseScope;
let collections = suspenseCollectionsByClient.get(scope);
if (!collections) {
collections = /* @__PURE__ */ new Map();
suspenseCollectionsByClient.set(scope, collections);
}
return collections;
}
function createDerivedIdentityProfiler() {
try {
if (process.env.NODE_ENV !== `production`) {
return { renderCount: 0, totalMs: 0, maxMs: 0, warned: false };
}
} catch {
}
return void 0;
}
function getCurrentTime() {
return typeof performance !== `undefined` && typeof performance.now === `function` ? performance.now() : Date.now();
}
function getWarningCallsite(stackIndex) {
const stack = new Error().stack ?? `unknown`;
const lines = stack.split(`
`);
const userFrame = lines.slice(1).find(
(line) => !line.includes(`useLiveQuery.ts`) && !line.includes(`useLiveSuspenseQuery.ts`) && !line.includes(`useLiveInfiniteQuery.ts`)
);
return userFrame?.trim() ?? lines[stackIndex]?.trim() ?? stack;
}
function warnDerivedIdentityHotPath(profiler, durationMs) {
if (profiler.warned || !(shouldWarnInDevelopment(`TANSTACK_DB_DISABLE_QUERY_IDENTITY_WARNINGS`) && process.env.NODE_ENV !== `production`)) {
return;
}
const isSlowSingleRender = durationMs >= DERIVED_IDENTITY_SINGLE_RENDER_WARN_MS;
const isHotRenderPath = profiler.renderCount >= DERIVED_IDENTITY_RENDER_COUNT_WARN_THRESHOLD && profiler.totalMs >= DERIVED_IDENTITY_TOTAL_WARN_MS;
if (!isSlowSingleRender && !isHotRenderPath) {
return;
}
const callsite = getWarningCallsite(5);
if (warnedDerivedIdentityCallsites.has(callsite)) {
profiler.warned = true;
return;
}
warnedDerivedIdentityCallsites.add(callsite);
profiler.warned = true;
const reason = isSlowSingleRender ? `one render took ${durationMs.toFixed(1)}ms` : `${profiler.renderCount} renders took ${profiler.totalMs.toFixed(1)}ms`;
console.warn(
`[useLiveQuery] Deriving live query identity from structured query IR is running on a hot render path (${reason}, max ${profiler.maxMs.toFixed(1)}ms). Provide an explicit queryKey to skip rebuilding and hashing the IR on every render: useLiveQuery({ queryKey: [...], query }).`
);
}
function getIdentityScope(claims, dbClient, hookName) {
return {
claims,
client: dbClient,
bindingName: hookName,
onUnboundDescriptor: getUnboundDescriptorReporter(dbClient, hookName)
};
}
function prepareDerivedQuery(value, profiler, scope, deferredCollections) {
const shouldProfile = profiler !== void 0 && /* @__PURE__ */ shouldWarnInDevelopment(
`TANSTACK_DB_DISABLE_QUERY_IDENTITY_WARNINGS`
);
const start = shouldProfile && process.env.NODE_ENV !== `production` ? getCurrentTime() : 0;
const preparedValue = prepareLiveQueryValue(
value,
scope.client,
deferredCollections
);
try {
return {
value: preparedValue,
identityDeps: [`derived`, identifyLiveQuery(preparedValue, scope)]
};
} finally {
if (shouldProfile && process.env.NODE_ENV !== `production`) {
const durationMs = getCurrentTime() - start;
profiler.renderCount += 1;
profiler.totalMs += durationMs;
profiler.maxMs = Math.max(profiler.maxMs, durationMs);
warnDerivedIdentityHotPath(profiler, durationMs);
}
}
}
function useLiveQuery(configOrQueryOrCollection) {
return useLiveQueryImpl(configOrQueryOrCollection);
}
function useLiveQueryForSuspense(configOrQueryOrCollection) {
return useLiveQueryImpl(configOrQueryOrCollection, true);
}
function useLiveQueryImpl(configOrQueryOrCollection, forSuspense = false) {
const contextDbClient = useOptionalDbClient();
const inputIsCollection = isCollection(configOrQueryOrCollection);
const dbClient = (inputIsCollection || typeof configOrQueryOrCollection !== `object` ? void 0 : configOrQueryOrCollection?.client) ?? contextDbClient;
const instanceRef = useRef(null);
const instance = instanceRef.current ??= {
collection: null,
identityDeps: null,
derivedIdentityProfiler: createDerivedIdentityProfiler(),
deferredCollections: /* @__PURE__ */ new Set(),
sourceIds: createLiveQuerySourceIdClaims()
};
const resumeDeferredCollections = () => {
instance.cancelReclaimRelease?.();
instance.cancelReclaimRelease = void 0;
resumeDeferredLiveQueryCollections(instance.deferredCollections);
};
try {
const queryKey = !inputIsCollection ? getLiveQueryKey(configOrQueryOrCollection) : void 0;
let preparedQueryValue = unpreparedQueryValue;
let identityDeps;
let streamIdentity;
let identityError;
if (queryKey) {
identityDeps = queryKey;
streamIdentity = [`queryKey`, queryKey];
} else if (inputIsCollection) {
identityDeps = [];
streamIdentity = [`collection`, configOrQueryOrCollection.id];
} else {
const preparation = prepareDerivedQuery(
configOrQueryOrCollection,
instance.derivedIdentityProfiler,
getIdentityScope(instance.sourceIds, dbClient, `useLiveQuery`),
instance.deferredCollections
);
preparedQueryValue = preparation.value;
identityDeps = preparation.identityDeps;
streamIdentity = preparation.identityDeps;
}
let queryHash;
try {
queryHash = getStableValueHash(streamIdentity, `queryKey`);
} catch (error) {
if (!(error instanceof UnhashableQueryIRError) || queryKey !== void 0)
throw error;
identityError = error;
}
let needsNewCollection = !instance.collection || (inputIsCollection ? instance.config !== configOrQueryOrCollection : instance.client !== dbClient || !deepEquals(instance.identityDeps, identityDeps));
if (needsNewCollection && !inputIsCollection && preparedQueryValue === unpreparedQueryValue) {
preparedQueryValue = prepareLiveQueryValue(
configOrQueryOrCollection,
dbClient,
instance.deferredCollections
);
}
let commitWindow;
if (needsNewCollection) {
if (inputIsCollection) {
if (/* @__PURE__ */ shouldWarnInDevelopment(
`TANSTACK_DB_DISABLE_QUERY_IDENTITY_WARNINGS`
) && process.env.NODE_ENV !== `production` && configOrQueryOrCollection.config?.syncMode === `on-demand`) {
console.warn(
`[useLiveQuery] Warning: Passing a collection with syncMode "on-demand" directly to useLiveQuery will not load any data. In on-demand mode, data is only loaded when queries with predicates request it.
Instead, use a query builder function:
const { data } = useLiveQuery({ query: (q) => q.from({ c: myCollection }).select(({ c }) => c) })
Or switch to syncMode "eager" if you want all data to sync automatically.`
);
}
configOrQueryOrCollection.startSyncImmediate();
instance.collection = configOrQueryOrCollection;
} else {
const suspenseKey = forSuspense && queryHash ? getSourceQualifiedSuspenseKey(
preparedQueryValue,
queryHash,
dbClient
) : void 0;
const suspenseCollections = suspenseKey ? getSuspenseCollections(dbClient) : void 0;
const suspenseCollection = suspenseCollections?.get(
suspenseKey
)?.collection;
if (suspenseCollection) {
instance.collection = suspenseCollection;
} else {
if (!dbClient && queryKey !== void 0) {
getPreparedSources(preparedQueryValue, dbClient, `useLiveQuery`);
}
instance.collection = resolveLiveQueryValue(preparedQueryValue, {
gcTime: forSuspense ? DEFAULT_SUSPENSE_GC_TIME_MS : DEFAULT_GC_TIME_MS,
// Hydration and Suspense key the live-query Collection by identity,
// so they never pool, even when the config passes a pool.
pool: forSuspense || dbClient ? false : void 0,
previousCollection: forSuspense || dbClient ? void 0 : instance.collection,
windowOwner: instance,
onWindowChange: (move) => {
commitWindow = move;
},
// Without a DbClient, Suspense starts it through a preload below.
startSync: !forSuspense || dbClient !== void 0
});
if (instance.collection && !isCollection(preparedQueryValue)) {
instance.cancelReclaimRelease?.();
instance.cancelReclaimRelease = releaseDeferredOnReclaim(
instance.collection,
instance.deferredCollections
);
}
if (suspenseCollections && instance.collection) {
const collection = instance.collection;
const releaseCollection = () => {
const entry = suspenseCollections.get(suspenseKey);
if (entry?.collection !== collection) return;
suspenseCollections.delete(suspenseKey);
entry.removeStatusListeners.forEach((remove) => remove());
};
const retireAfterRetry = () => setTimeout(releaseCollection, 0);
suspenseCollections.set(suspenseKey, {
collection,
removeStatusListeners: [
collection.on(`status:cleaned-up`, releaseCollection),
collection.on(`status:error`, retireAfterRetry),
collection.on(`status:crashed`, retireAfterRetry)
]
});
}
}
if (!commitWindow) instance.identityDeps = [...identityDeps];
}
instance.config = configOrQueryOrCollection;
instance.client = dbClient;
instance.queryHash = queryHash;
instance.identityError = identityError;
}
if (commitWindow) needsNewCollection = false;
useWindowCommitEffect(() => {
if (commitWindow) {
instance.identityDeps = [...identityDeps];
commitWindow();
}
});
if (needsNewCollection) {
const observer2 = instance.observer = createLiveQueryObserver(
instance.collection,
{
mode: `wholesale`,
client: dbClient,
queryHash: instance.queryHash,
onPreload: resumeDeferredCollections,
suspendUntilReady: forSuspense
}
);
instance.subscribe = (onStoreChange) => {
const unsubscribe = observer2.subscribe(onStoreChange);
resumeDeferredCollections();
return unsubscribe;
};
}
const observer = instance.observer;
if (forSuspense && !dbClient && (instance.collection?.status === `idle` || instance.collection?.status === `cleaned-up`)) {
observer.preload().catch(() => {
});
}
const returned = useSyncExternalStore(
instance.subscribe,
() => observer.getSnapshot(),
() => observer.getServerSnapshot(forSuspense)
);
if (forSuspense) {
return {
result: returned,
client: dbClient,
queryHash: instance.queryHash,
identityError: instance.identityError,
observer,
deferredCollections: instance.deferredCollections
};
}
return returned;
} catch (error) {
return rejectLiveQueryInput(instance.deferredCollections, error);
}
}
export {
createDerivedIdentityProfiler,
getIdentityScope,
prepareDerivedQuery,
prepareLiveQueryValue2 as prepareQueryValue,
useLiveQuery,
useLiveQueryForSuspense
};
//# sourceMappingURL=useLiveQuery.js.map