diff --git a/cms/src/components/content/ContentDisplayCard.spec.ts b/cms/src/components/content/ContentDisplayCard.spec.ts index c6add65680..6b59fdfc9d 100644 --- a/cms/src/components/content/ContentDisplayCard.spec.ts +++ b/cms/src/components/content/ContentDisplayCard.spec.ts @@ -95,6 +95,7 @@ describe("ContentDisplayCard", () => { parentType: mockData.mockEnglishContentDto.parentType as DocType.Post | DocType.Tag, languageId: engNonDefault._id, languages: [engNonDefault, mockData.mockLanguageDtoFra, swaDefault], + translations: [mockData.mockEnglishContentDto], }, }); diff --git a/cms/src/components/content/ContentDisplayCard.vue b/cms/src/components/content/ContentDisplayCard.vue index 345a09e1b1..ff1e6777d4 100644 --- a/cms/src/components/content/ContentDisplayCard.vue +++ b/cms/src/components/content/ContentDisplayCard.vue @@ -9,7 +9,7 @@ import { AclPermission, verifyAccess, type GroupDto, - useHybridQueryWithState, + useHasLocalChanges, } from "luminary-shared"; import { computed, ref, watch } from "vue"; import LBadge from "../common/LBadge.vue"; @@ -36,6 +36,12 @@ type Props = { * suppress it. (Related/fuzzy search leaves it on.) */ hideBodySnippet?: boolean; + /** + * Every translation of this card's parent, supplied by the list. Sourced once for + * the whole list rather than per card: an unbounded per-parent Content query always + * hits the API supplement, so a per-card one costs one request per rendered row. + */ + translations: ContentDto[]; }; const props = defineProps(); @@ -46,19 +52,7 @@ const highlight = computed(() => : undefined, ); -// All translations of this card's parent (no language filter), Dexie-first via HybridQuery. The -// top-level `type` is required — without it HybridQuery.readType returns undefined and routes -// API-only. `parentId` alone scopes to the parent (parentType is redundant given the unique -// parentId), and `{ type, parentId }` matches the `[type+parentId]` index — no full-table-scan warning. -const { output: contentDocs, hasLocalChanges } = useHybridQueryWithState( - () => ({ - selector: { - type: DocType.Content, - parentId: props.contentDoc.parentId, - }, - }), - { live: true }, -); +const hasLocalChanges = useHasLocalChanges(); const isLocalChange = computed(() => hasLocalChanges.value(props.contentDoc._id)); const tagsContent = ref([]); @@ -70,11 +64,10 @@ const accessibleLanguages = computed(() => ); watch( - contentDocs, + () => [props.contentDoc.parentTags, props.languageId] as const, async () => { - if (!contentDocs.value || contentDocs.value.length === 0) return; tagsContent.value = await db.whereParent( - contentDocs.value[0].parentTags, + props.contentDoc.parentTags, DocType.Tag, props.languageId, ); @@ -152,10 +145,10 @@ const navigateTo = computed(() => { {{ language.languageCode }} @@ -173,10 +166,10 @@ const navigateTo = computed(() => { {{ language.languageCode }} diff --git a/cms/src/components/content/ContentOverview/ContentOverview.vue b/cms/src/components/content/ContentOverview/ContentOverview.vue index 83930dd119..94533cb045 100644 --- a/cms/src/components/content/ContentOverview/ContentOverview.vue +++ b/cms/src/components/content/ContentOverview/ContentOverview.vue @@ -130,6 +130,33 @@ const search = useContentSearchQuery( const searchIsStale = search.isStale; const contentDocs = computed(() => (searchActive.value ? search.docs.value : browse.docs.value)); + +// Every rendered row needs its parent's other translations for the language badges. An +// unbounded per-parent Content query always hits the API supplement, so asking per card +// costs one request per row; one `$in` query for the whole page collapses that (and the +// per-row Dexie subscriptions) into a single source. +const rowTranslations = useHybridQuery( + () => ({ + selector: { + type: DocType.Content, + parentId: { $in: [...new Set(contentDocs.value.map((d) => d.parentId))] }, + }, + }), + { + live: true, + keepPreviousResult: true, + stripFields: ["fts", "ftsTokenCount", "text", "_rev"], + }, +); +const translationsByParent = computed(() => { + const byParent = new Map(); + for (const doc of rowTranslations.value) { + const list = byParent.get(doc.parentId); + if (list) list.push(doc); + else byParent.set(doc.parentId, [doc]); + } + return byParent; +}); const isLoading = computed(() => searchActive.value ? search.isLoading.value : browse.isLoading.value, ); @@ -284,6 +311,7 @@ const createNew = () => { :key="contentDoc._id" :groups="groups.filter((group) => contentDoc.memberOf?.includes(group._id))" :content-doc="contentDoc as ContentDto" + :translations="translationsByParent.get(contentDoc.parentId) ?? []" :parent-type="queryOptions.parentType" :language-id="queryOptions.languageId" :languages="languages" diff --git a/shared/src/util/HybridQuery/HybridQuery.spec.ts b/shared/src/util/HybridQuery/HybridQuery.spec.ts index ca00af31d5..107ee0e818 100644 --- a/shared/src/util/HybridQuery/HybridQuery.spec.ts +++ b/shared/src/util/HybridQuery/HybridQuery.spec.ts @@ -1729,6 +1729,394 @@ describe("HybridQuery", () => { // is documented (README) rather than tested here. }); + // `$limit` growth is the "load more" shape: the same query, asking for a wider + // window. Both clients page this way (the app's feeds, the CMS's overviews), so + // the widened generation must keep what it already holds rather than falling back + // to its local-only subset while the new supplement is in flight. + describe("$limit growth (window widening)", () => { + const contentDoc = (id: string, publishDate = 500, updatedTimeUtc = 1) => ({ + _id: id, + type: "content", + parentTags: ["A"], + publishDate, + updatedTimeUtc, + }); + // Drive the CURRENT generation's next local emission. + const emitLocal = async (docs: any[]) => { + mocks.liveRefs[mocks.liveRefs.length - 1]!.ref.value = docs; + await flush(); + }; + // A live content feed whose window size is a ref — the infinite-scroll shape. + const setupLimit = async (limit: { value: number }, opts: Record = {}) => { + postHttpMock.mockResolvedValue({ docs: [] }); + const q = track( + new HybridQuery( + () => ({ + selector: { + $and: [ + { type: "content" }, + { parentTags: { $elemMatch: { $in: ["A"] } } }, + ], + }, + $sort: [{ publishDate: "desc" as const }], + $limit: limit.value, + }), + { live: true, keepPreviousResult: true, ...opts }, + ), + ); + await flush(); + return q; + }; + + it("a pure $limit growth keeps the remote supplement instead of refetching it", async () => { + const limit = ref(2); + postHttpMock.mockResolvedValueOnce({ docs: [contentDoc("r1", 500, 2)] }); + const q = await setupLimit(limit); + await emitLocal([contentDoc("l1", 2000, 1)]); + expect(q.output.value.map((d) => d._id)).toEqual(["l1", "r1"]); + + const snapshots: string[][] = []; + const stop = watch(q.output, (v) => snapshots.push(v.map((d) => d._id)), { + flush: "sync", + }); + + postHttpMock.mockResolvedValueOnce({ docs: [contentDoc("r2", 400, 3)] }); + limit.value = 4; + await flush(); + // The widened generation re-reads the local source, which still holds one doc. + await emitLocal([contentDoc("l1", 2000, 1)]); + await flush(); + stop(); + + expect(q.output.value.map((d) => d._id)).toEqual(["l1", "r1", "r2"]); + // The supplement it already had was never dropped, so the window only grew — + // it never fell back to the local-only subset while the new POST was in flight. + expect(snapshots.every((s) => s.length >= 2)).toBe(true); + expect(snapshots.every((s) => s.includes("r1"))).toBe(true); + }); + + it("re-decides the supplement for the widened window", async () => { + const limit = ref(2); + const q = await setupLimit(limit); + await emitLocal([contentDoc("l1", 2000, 1)]); + const postsBefore = postHttpMock.mock.calls.length; + + limit.value = 4; + await flush(); + await emitLocal([contentDoc("l1", 2000, 1)]); + + expect(mocks.liveRefs.length).toBe(2); // new local subscription for the wider read + expect(postHttpMock.mock.calls.length).toBe(postsBefore + 1); + expect((postHttpMock.mock.calls.at(-1)![1] as any).limit).toBe(3); // 4 − 1 local + expect(q.output.value.length).toBeGreaterThan(0); + }); + + it("re-applies sort and the NEW limit to the carried-over docs", async () => { + const limit = ref(2); + // A remote doc that sorts ABOVE the local one: order must come from $sort, + // not from which contribution a doc happens to sit in. + postHttpMock.mockResolvedValueOnce({ docs: [contentDoc("r-high", 3000, 2)] }); + const q = await setupLimit(limit); + await emitLocal([contentDoc("l1", 2000, 1)]); + expect(q.output.value.map((d) => d._id)).toEqual(["r-high", "l1"]); + + postHttpMock.mockResolvedValueOnce({ docs: [contentDoc("r-low", 100, 3)] }); + limit.value = 3; + await flush(); + await emitLocal([contentDoc("l1", 2000, 1)]); + await flush(); + + expect(q.output.value.map((d) => d._id)).toEqual(["r-high", "l1", "r-low"]); + }); + + it("does not duplicate a doc the widened supplement re-returns, and takes the newer copy", async () => { + const limit = ref(2); + postHttpMock.mockResolvedValueOnce({ docs: [contentDoc("r1", 500, 2)] }); + const q = await setupLimit(limit); + await emitLocal([contentDoc("l1", 2000, 1)]); + + // The wider POST covers the narrower one's range, so it returns r1 again — + // here with a newer revision — plus one more. + postHttpMock.mockResolvedValueOnce({ + docs: [contentDoc("r1", 500, 9), contentDoc("r2", 400, 3)], + }); + limit.value = 4; + await flush(); + await emitLocal([contentDoc("l1", 2000, 1)]); + await flush(); + + expect(q.output.value.map((d) => d._id)).toEqual(["l1", "r1", "r2"]); + expect(q.output.value.find((d) => d._id === "r1")!.updatedTimeUtc).toBe(9); + }); + + it("swaps the socket listener rather than doubling it", async () => { + const limit = ref(2); + const q = await setupLimit(limit); + await emitLocal([contentDoc("l1", 2000, 1)]); + expect(mocks.socketDataHandlers.size).toBe(1); + + limit.value = 4; + await flush(); + await emitLocal([contentDoc("l1", 2000, 1)]); + + expect(mocks.socketDataHandlers.size).toBe(1); + // The listener belongs to the widened generation and still feeds output. + mocks.emitSocket([contentDoc("s1", 300, 4)]); + expect(q.output.value.map((d) => d._id)).toContain("s1"); + }); + + it("keeps a socket delete suppressed across the growth", async () => { + // A limit the local read can't fill, so a supplement is owed and the socket + // listener that carries deletes is attached. + const limit = ref(4); + const q = await setupLimit(limit); + await emitLocal([contentDoc("l1", 2000, 1), contentDoc("l2", 1900, 1)]); + expect(q.output.value.map((d) => d._id)).toEqual(["l1", "l2"]); + expect(mocks.socketDataHandlers.size).toBe(1); + + mocks.emitSocket([ + { + _id: "del-l2", + type: DocType.DeleteCmd, + docType: "content", + docId: "l2", + updatedTimeUtc: 50, + }, + ]); + expect(q.output.value.map((d) => d._id)).toEqual(["l1"]); + + limit.value = 6; + await flush(); + // Dexie hasn't caught up yet and still re-emits the deleted doc; the + // tombstone carried over with the contributions, so it stays suppressed. + await emitLocal([contentDoc("l1", 2000, 1), contentDoc("l2", 1900, 1)]); + expect(q.output.value.map((d) => d._id)).toEqual(["l1"]); + }); + + it("re-enters and settles isFetching across the growth", async () => { + const limit = ref(2); + const q = await setupLimit(limit); + await emitLocal([contentDoc("l1", 2000, 1)]); + await flush(); + expect(q.isFetching.value).toBe(false); + + limit.value = 4; + await flush(); + expect(q.isFetching.value).toBe(true); // widened window is loading again + + await emitLocal([contentDoc("l1", 2000, 1)]); + await flush(); + expect(q.isFetching.value).toBe(false); + }); + + it("applies to a non-synced (API-only) type too", async () => { + const limit = ref(2); + postHttpMock.mockResolvedValueOnce({ + docs: [{ _id: "u1", type: "user", updatedTimeUtc: 1 }], + }); + const q = track( + new HybridQuery( + () => ({ selector: { type: "user" }, $limit: limit.value }), + { live: true, keepPreviousResult: true }, + ), + ); + await flush(); + expect(q.output.value.map((d) => d._id)).toEqual(["u1"]); + + // An API-only type has no local read to fall back on, so the carry-over is + // the only thing holding its window up while the wider POST is in flight. + // Fail that POST to prove the docs really are still held, not just painted: + // the next recompute (a socket upsert) must ADD to them, not replace them. + postHttpMock.mockRejectedValueOnce(new Error("network")); + limit.value = 4; + await flush(); + expect(q.output.value.map((d) => d._id)).toEqual(["u1"]); + + mocks.emitSocket([{ _id: "u2", type: "user", updatedTimeUtc: 2 }]); + expect(q.output.value.map((d) => d._id)).toEqual(["u1", "u2"]); + }); + + it("keeps the fetched tail when the widened supplement fails outright", async () => { + const limit = ref(4); + postHttpMock.mockResolvedValueOnce({ + docs: [contentDoc("r1", 500, 2), contentDoc("r2", 400, 3)], + }); + const q = await setupLimit(limit); + await emitLocal([contentDoc("l1", 2000, 1), contentDoc("l2", 1900, 1)]); + await flush(); + expect(q.output.value.map((d) => d._id)).toEqual(["l1", "l2", "r1", "r2"]); + + // Load more on a flaky connection: the local read lands as always, but the + // supplement never does. The window must hold what it already had rather + // than dropping to the local-only subset. + postHttpMock.mockRejectedValueOnce(new Error("network")); + limit.value = 6; + await flush(); + await emitLocal([contentDoc("l1", 2000, 1), contentDoc("l2", 1900, 1)]); + await flush(); + + expect(q.output.value.map((d) => d._id)).toEqual(["l1", "l2", "r1", "r2"]); + }); + + it("applies to a fully-synced (Dexie-only) type too", async () => { + mocks.syncList.value = [{ chunkType: "group" }]; + const limit = ref(2); + const q = track( + new HybridQuery( + () => ({ selector: { type: "group" }, $limit: limit.value }), + { live: true, keepPreviousResult: true }, + ), + ); + await flush(); + await emitLocal([{ _id: "g1", type: "group", updatedTimeUtc: 1 }]); + expect(q.output.value.map((d) => d._id)).toEqual(["g1"]); + + limit.value = 4; + await flush(); + expect(postHttpMock).not.toHaveBeenCalled(); // synced type: still Dexie-only + expect(q.output.value.map((d) => d._id)).toEqual(["g1"]); // bridged + + await emitLocal([ + { _id: "g1", type: "group", updatedTimeUtc: 1 }, + { _id: "g2", type: "group", updatedTimeUtc: 2 }, + ]); + expect(q.output.value.map((d) => d._id)).toEqual(["g1", "g2"]); + }); + + it("a shrinking $limit is not a growth — contributions are discarded", async () => { + const limit = ref(4); + postHttpMock.mockResolvedValueOnce({ docs: [contentDoc("r1", 500, 2)] }); + const q = await setupLimit(limit); + await emitLocal([contentDoc("l1", 2000, 1)]); + expect(q.output.value.map((d) => d._id)).toEqual(["l1", "r1"]); + + postHttpMock.mockResolvedValueOnce({ docs: [] }); + limit.value = 2; + await flush(); + await emitLocal([contentDoc("l1", 2000, 1)]); + + expect(q.output.value.map((d) => d._id)).toEqual(["l1"]); // remote dropped + }); + + it("a selector change at the same $limit is not a growth — contributions are discarded", async () => { + const cats = ref(["A"]); + postHttpMock.mockResolvedValueOnce({ docs: [contentDoc("r1", 500, 2)] }); + const q = track( + new HybridQuery( + () => ({ + selector: { + $and: [ + { type: "content" }, + { parentTags: { $elemMatch: { $in: cats.value } } }, + ], + }, + $limit: 4, + }), + { live: true, keepPreviousResult: true }, + ), + ); + await flush(); + await emitLocal([contentDoc("l1", 2000, 1)]); + expect(q.output.value.map((d) => d._id)).toEqual(["l1", "r1"]); + + postHttpMock.mockResolvedValueOnce({ docs: [] }); + cats.value = ["B"]; + await flush(); + await emitLocal([{ ...contentDoc("b1", 2000, 1), parentTags: ["B"] }]); + + expect(q.output.value.map((d) => d._id)).toEqual(["b1"]); + }); + + it("a sort change alongside the wider limit is not a growth", async () => { + const limit = ref(2); + const direction = ref("desc"); + postHttpMock.mockResolvedValueOnce({ docs: [contentDoc("r1", 500, 2)] }); + const q = track( + new HybridQuery( + () => ({ + selector: { type: "content" }, + $sort: [{ publishDate: direction.value }], + $limit: limit.value, + }), + { live: true, keepPreviousResult: true }, + ), + ); + await flush(); + await emitLocal([contentDoc("l1", 2000, 1)]); + expect(q.output.value.map((d) => d._id)).toEqual(["l1", "r1"]); + + postHttpMock.mockResolvedValueOnce({ docs: [] }); + limit.value = 4; + direction.value = "asc"; + await flush(); + await emitLocal([contentDoc("l1", 2000, 1)]); + + expect(q.output.value.map((d) => d._id)).toEqual(["l1"]); + }); + + it("acquiring a $limit where there was none is not a growth", async () => { + const limit = ref(undefined); + postHttpMock.mockResolvedValueOnce({ docs: [contentDoc("r1", 500, 2)] }); + const q = track( + new HybridQuery( + () => ({ selector: { type: "content" }, $limit: limit.value }), + { live: true, keepPreviousResult: true }, + ), + ); + await flush(); + await emitLocal([contentDoc("l1", 2000, 1)]); + expect(q.output.value.map((d) => d._id)).toEqual(["l1", "r1"]); + + postHttpMock.mockResolvedValueOnce({ docs: [] }); + limit.value = 4; + await flush(); + await emitLocal([contentDoc("l1", 2000, 1)]); + + expect(q.output.value.map((d) => d._id)).toEqual(["l1"]); + }); + + it("without keepPreviousResult a $limit growth refetches as before", async () => { + const limit = ref(2); + postHttpMock.mockResolvedValueOnce({ docs: [contentDoc("r1", 500, 2)] }); + const q = await setupLimit(limit, { keepPreviousResult: false }); + await emitLocal([contentDoc("l1", 2000, 1)]); + expect(q.output.value.map((d) => d._id)).toEqual(["l1", "r1"]); + + postHttpMock.mockResolvedValueOnce({ docs: [] }); + limit.value = 4; + await flush(); + await emitLocal([contentDoc("l1", 2000, 1)]); + + expect(q.output.value.map((d) => d._id)).toEqual(["l1"]); + }); + + it("does not re-seed from the response cache on a growth", async () => { + const limit = ref(2); + postHttpMock.mockResolvedValueOnce({ docs: [contentDoc("r1", 500, 2)] }); + const q = await setupLimit(limit, { cache: true }); + await emitLocal([contentDoc("l1", 2000, 1)]); + await flush(); + expect(q.output.value.map((d) => d._id)).toEqual(["l1", "r1"]); + + // A stale entry under the same structural key must not repaint: the widened + // generation already holds the real contributions. + const key = structuralCacheKey({ + selector: { + $and: [{ type: "content" }, { parentTags: { $elemMatch: { $in: ["A"] } } }], + }, + $sort: [{ publishDate: "desc" }], + $limit: 2, + }); + writeResponseCache(key, { local: [contentDoc("stale", 9999, 1)], remote: [] }); + + postHttpMock.mockResolvedValueOnce({ docs: [] }); + limit.value = 4; + await flush(); + + expect(q.output.value.map((d) => d._id)).toEqual(["l1", "r1"]); + }); + }); + describe("response caching (cache: true)", () => { // A Dexie-only synced type keeps the basic tests focused on the seed path (no // POST, no watchers). The seed runs synchronously in the constructor, so an diff --git a/shared/src/util/HybridQuery/HybridQuery.ts b/shared/src/util/HybridQuery/HybridQuery.ts index 55198e024d..43ea591914 100644 --- a/shared/src/util/HybridQuery/HybridQuery.ts +++ b/shared/src/util/HybridQuery/HybridQuery.ts @@ -155,6 +155,19 @@ export function queryLocal( return mangoToDexie(db.docs, query); } +/** + * Serialize a query for identity comparison, ignoring `$limit`. Two queries with the + * same shape key describe the same window and differ only in how much of it they ask + * for. The `undefined` sentinel matches the constructor's dependency-tracking watch: + * `JSON.stringify` drops undefined-valued fields, but Mango reads `{ x: undefined }` as + * "x must be missing", so the two must not serialize alike. + */ +function shapeKeyOf(query: MangoQuery): string { + const shape: MangoQuery = { ...query }; + delete shape.$limit; + return JSON.stringify(shape, (_k, v) => (v === undefined ? "\u0000undef" : v)); +} + /** Options for {@link HybridQuery}. */ export type HybridQueryOptions = { /** @@ -426,6 +439,12 @@ export class HybridQuery { // reach the live `output` (heap). See {@link HybridQueryOptions.stripFields}. private readonly _stripFields: string[]; private _cacheKey = ""; + // Serialized previous query MINUS `$limit`, so `_rebuild` can recognise a rebuild + // that only GROWS the window. See `_isLimitGrowth`. + private _shapeKey = ""; + // True for the generation currently being built when it is a pure `$limit` growth of + // the previous one — the contributions carry over instead of being refetched. + private _limitGrowth = false; // Owns the response-cache seed bookkeeping: how long a seeded first-paint window // survives before authoritative reads replace it. The seed-to-live transition // table lives in the `SeedRetention` module. @@ -504,12 +523,36 @@ export class HybridQuery { } } + /** + * True when `query` differs from the generation in flight ONLY by a larger `$limit`. + * Such a rebuild widens the same window rather than describing a different one, so + * the documents already gathered still satisfy it and can be carried over. Gated on + * `keepPreviousResult`, by which the caller has declared this query a re-narrowing + * of one list (see {@link HybridQueryOptions.keepPreviousResult}). + */ + private _isLimitGrowth(query: MangoQuery, shapeKey: string): boolean { + return ( + this._keepPreviousResult && + this._shapeKey !== "" && + shapeKey === this._shapeKey && + typeof query.$limit === "number" && + typeof this._limit === "number" && + query.$limit > this._limit + ); + } + /** * (Re)build a generation: tear down the previous generation's subscriptions, * snapshot the new query, reset per-generation state, and route. `output` is cleared * unless the caller opted into `keepPreviousResult` — a re-narrowed list keeps the * previous window so it doesn't blank; the provably-empty branch in `_run` clears it * even then, since that case never recomputes. + * + * A pure `$limit` GROWTH additionally keeps the local + remote contributions + * themselves ({@link _isLimitGrowth}), so the list only ever gains rows: without it + * an API-supplemented feed drops back to its local-only subset for the length of the + * new supplement's round trip, which collapses the scroll height under an infinite + * scroller. */ private _rebuild(query: MangoQuery): void { if (this._disposed) return; @@ -520,15 +563,25 @@ export class HybridQuery { this._generationDisposers.clear(); ds.forEach((fn) => fn()); + const shapeKey = shapeKeyOf(query); + const limitGrowth = this._isLimitGrowth(query, shapeKey); + this._shapeKey = shapeKey; + this._limitGrowth = limitGrowth; + this._generation++; - this._local = []; - this._remote = []; + if (!limitGrowth) { + this._local = []; + this._remote = []; + this._tombstones.clear(); + // The seed bookkeeping describes the contributions; when those carry over it + // must carry over with them (and `_run` then skips re-seeding). + this._seed.reset(); + } // Only a caller that declared its query a narrowing of the same list keeps the // previous window. The length guard keeps the initial build a no-op. if (!this._keepPreviousResult && this.output.value.length) this.output.value = []; - this._seed.reset(); + // Re-decided for the widened window: the supplement covers the new shortfall. this._apiDecided = false; - this._tombstones.clear(); // Re-enter loading for the new generation. _generation++ above has already // disarmed any stale callback (the gen guard), so these synchronous writes are // safe; _run adjusts _remotePending per route (and clears _localPending for the @@ -662,7 +715,7 @@ export class HybridQuery { // NB: do NOT settle loading here. The seed is a first-paint accelerant; the // authoritative local read and remote POST are still in flight, so isFetching // stays true (painted-from-cache is still "fetching", not "settled"). - if (this._cache) { + if (this._cache && !this._limitGrowth) { const seed = readResponseCache(this._cacheKey); if (seed) { // Strip defensively: a cache written before `stripFields` grew diff --git a/shared/src/util/HybridQuery/README.md b/shared/src/util/HybridQuery/README.md index 32f84933dc..17d078aec2 100644 --- a/shared/src/util/HybridQuery/README.md +++ b/shared/src/util/HybridQuery/README.md @@ -324,6 +324,16 @@ paint **another** document's window on first paint. Give such a query a per-iden A provably-empty new query (e.g. an empty `$in`) always clears `output`, regardless of this option — that branch never recomputes, so a kept window would never be replaced. +**Growing `$limit` keeps the fetched documents too, not just `output`.** When a rebuild +differs from the generation in flight only by a *larger* `$limit`, the local and remote +contributions carry over instead of being emptied, and the supplement is re-decided for +the widened window. Everything already gathered still satisfies the wider query, so the +list only ever gains rows. Without this, an API-supplemented feed falls back to its +local-only subset for the length of the new supplement's round trip — under an infinite +scroller that collapses the scroll height and throws the reader back to the top. Applies +only with `keepPreviousResult: true`; any other change to the query (selector, sort, a +*shrinking* limit) is a normal rebuild. + ### `useHybridQuery(query, options?)` — composable A thin wrapper that constructs the class and returns **only** its `output` ref —