← Files WixARCHIVED FILE
skills/wix-headless-templates/cms/app/wix/cms/collection-store.ts
9.03 KB · Oct 8, 2026 · 12:02 UTC
// One collection as list state, framework-free — the logic behind useCollection, usable from React
// (useCollection wraps it with useSyncExternalStore), from a static page or Vue/Svelte (subscribe
// and render), or as the specification for a port. Same shape as the storefront stores: state,
// actions, subscribe/getState, emit after every change.
//
// The query (collection, filters, sort, page size, includes) is identified BY VALUE; a changed
// query refetches from the first page, a late response from a superseded query is dropped.
// Stale-while-refetch: once items are on screen they STAY there while a new query, page, or
// refresh runs — `fetching` flips instead of `items` going null; only the very first load shows
// skeletons (`loading`). Two ways to page, both at the source: loadMore() appends the next page
// (skip = items shown so far); goToPage(n) replaces the list with page n (0-based; `hasPrev`,
// `page`, and — with `withTotal` — `total`/`pageCount` drive a numbered pager). refresh() re-reads
// what is on screen after a write.
//
// SSR-friendly: seed with `initialItems` from the SAME query and no client fetch happens for the
// first page. One store per mounted listing (a page can hold two collections): createCollectionStore(),
// not a singleton.
import { queryItems } from "./items";
import type { CmsFilter, CmsItem, CmsSort } from "./types";
export interface CollectionQuery {
collectionId?: string;
filters?: CmsFilter[];
sort?: CmsSort[];
/** Page size (default 20). */
limit?: number;
/** Reference field keys to inline as full items. */
include?: string[];
}
export interface CollectionStoreOptions extends CollectionQuery {
collectionId: string;
/** Server-fetched first page — must come from the SAME query (filters/sort/limit/include). */
initialItems?: CmsItem[];
/** The server fetch's hasNext. Omitted → inferred (a full first page ⇒ assume more). */
initialHasNext?: boolean;
/** true → every query asks Wix for the total; `total` and `pageCount` fill in (a slower query). */
withTotal?: boolean;
/** The server fetch's total (it queried withTotal). */
initialTotal?: number | null;
}
/** Everything a listing surface renders from. Read it with getState() or through a subscription. */
export interface CollectionState {
/** null while the FIRST load is in flight — render skeletons, not an empty state. Never null again after. */
items: CmsItem[] | null;
hasNext: boolean;
/** A page before the one shown exists (page > 0). */
hasPrev: boolean;
/** 0-based index of the first page on screen (loadMore appends after it). */
page: number;
/** Matching items across the collection — only with `withTotal`, else null. */
total: number | null;
/** ceil(total / limit) — only with `withTotal`, else null. */
pageCount: number | null;
/** True while items is null and a load is running (the same condition, named). */
loading: boolean;
/** True while a new query, page, or refresh runs BEHIND the items on screen — dim them, don't drop them. */
fetching: boolean;
loadingMore: boolean;
error: string | null;
}
export interface CollectionStore {
getState(): CollectionState;
subscribe(listener: () => void): () => void;
/** Run the first query unless the seed already answered it. Call once when mounted (a browser). */
start(): void;
/** Stop reacting; drop late responses. */
stop(): void;
/** Change the query by value — same values keep the page, a change refetches page 0 (items stay on screen, `fetching`). */
setQuery(query: CollectionQuery): void;
/** Append the next page (skip = items shown so far). No-op while loading or when there is none. */
loadMore(): Promise<void>;
/** Replace the list with page n (0-based; skip = n × limit). No-op for a negative n or the page already shown. */
goToPage(n: number): void;
/** Re-read what is on screen (same query, same window) — after insertItem / patchItemFields / linkItems. */
refresh(): void;
/** Re-run the current query from page 0 after an error (skeletons again). */
retry(): void;
}
const queryKey = (q: Required<Pick<CollectionQuery, "collectionId" | "limit">> & CollectionQuery): string =>
// Dates in filters serialize to ISO, so this is stable.
JSON.stringify([q.collectionId, q.filters ?? null, q.sort ?? null, q.limit, q.include ?? null]);
export function createCollectionStore(options: CollectionStoreOptions): CollectionStore {
let collectionId = options.collectionId;
let filters = options.filters;
let sort = options.sort;
let limit = options.limit ?? 20;
let include = options.include;
const withTotal = options.withTotal ?? false;
const key = () => queryKey({ collectionId, filters, sort, limit, include });
let items: CmsItem[] | null = options.initialItems ?? null;
let hasNext = options.initialHasNext ?? (options.initialItems ? options.initialItems.length >= limit : false);
let page = 0;
let total: number | null = withTotal ? (options.initialTotal ?? null) : null;
let loadingMore = false;
let fetching = false;
let error: string | null = null;
// The query the latest request targets (null = nothing requested yet).
let pageKey: string | null = options.initialItems ? key() : null;
let inflight = false;
let started = false;
let generation = 0;
const listeners = new Set<() => void>();
let snapshot: CollectionState | null = null;
const emit = () => { snapshot = null; for (const fn of listeners) fn(); };
function getState(): CollectionState {
if (snapshot) return snapshot;
snapshot = {
items,
hasNext,
hasPrev: page > 0,
page,
total,
pageCount: total === null ? null : Math.ceil(total / limit),
loading: items === null,
fetching,
loadingMore,
error,
};
return snapshot;
}
/**
* Load page `nextPage` of the current query (`window` items wide — the page size, or the whole
* shown list on a refresh). Items already on screen stay while it runs (fetching); the first load
* has none, so it shows skeletons (loading).
*/
function load(nextPage: number, window = limit): void {
if (!started) return;
const k = key();
const id = ++generation;
pageKey = k; inflight = true;
error = null; loadingMore = false;
fetching = items !== null; // something is on screen → it stays, dimmed; nothing yet → skeletons (loading)
emit();
queryItems(collectionId, { filters, sort, limit: window, skip: nextPage * limit, include, withTotal })
.then((res) => {
if (generation !== id) return; // superseded — drop it
items = res.items; hasNext = res.hasNext; page = nextPage;
if (withTotal) total = res.total;
inflight = false; fetching = false;
emit();
})
.catch((e) => {
if (generation !== id) return;
// A first load that fails shows the error with an empty list; a refetch keeps what was on screen.
if (items === null) { items = []; hasNext = false; }
inflight = false; fetching = false;
error = e instanceof Error ? e.message : String(e);
emit();
});
}
return {
getState,
subscribe(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
},
start() {
if (started) return;
started = true;
// The SSR seed answered this exact query — no fetch; a dropped first load reruns.
if (pageKey !== key() || (items === null && !inflight)) load(0);
},
stop() {
started = false;
generation++;
inflight = false;
fetching = false;
},
setQuery(q) {
if (q.collectionId !== undefined) collectionId = q.collectionId;
filters = q.filters; sort = q.sort; include = q.include;
if (q.limit !== undefined) limit = q.limit;
if (key() === pageKey) return; // same values — same page
if (started) load(0);
},
async loadMore() {
const current = items;
if (!current || loadingMore || inflight) return;
const id = generation;
loadingMore = true; emit();
try {
const res = await queryItems(collectionId, { filters, sort, limit, include, withTotal, skip: page * limit + current.length });
if (generation !== id) return;
items = [...(items ?? []), ...res.items];
hasNext = res.hasNext;
if (withTotal) total = res.total;
} catch (e) {
if (generation !== id) return;
error = e instanceof Error ? e.message : String(e);
} finally {
if (generation === id) { loadingMore = false; emit(); }
}
},
goToPage(n) {
if (n < 0) return;
// Already showing exactly that page of this query (appended pages collapse back to one).
const shown = n === page && key() === pageKey && items !== null && !inflight && items.length <= limit;
if (shown) return;
load(n);
},
refresh() {
// The whole shown window (several appended pages read as one), or the page size before any answer.
load(page, items && items.length > limit ? items.length : limit);
},
retry() {
pageKey = null;
items = null; page = 0;
load(0);
},
};
}
SHA-256: 4135788ccd44831d517a51fb0e4019bdf773374c6a8dd4e8e10045664d3c9987