Wix-Data List Slow-Fast Rendering
Design Log 08: Wix Data List - Slow/Fast Rendering Pattern
Written for AI agents. See Log Methodology Note below for details.
Background
The wix-data plugin provides dynamic contract generation for Wix Data collections. The list component (collection-list.ts) and generator (list-contract-generator.ts) handle index and category pages.
Currently, the list component loads all items in the slow phase but the contract and component need refinement to match the pattern established in wix-stores category-page:
- First page rendered as slow (build-time/SSG)
- Additional pages loaded interactively via "load more"
Reference Implementations
- wix-stores category-page: Uses
products(slow) +loadedProducts(fast+interactive) pattern - Jay Design Log #50: Rendering phases in contracts
- Jay Design Log #75: Slow rendering jay-html to jay-html
- Jay Design Log #79: Linked contracts with mixed phase properties
Problem
- Contract incomplete: Current list contract doesn't distinguish between slow-rendered first page and interactively-loaded pages
- Missing loadedItems: No separate array for dynamically loaded items (like
loadedProductsin category-page) - Component needs update: Component should produce first page in slow phase, subsequent pages in interactive phase
Current Contract Structure
# Current list-contract-generator.ts output
name: BlogPostsList
tags:
- tag: items # No explicit phase (defaults to slow) ✓
type: sub-contract
repeated: true
trackBy: _id
...
- tag: totalCount # No phase specified
type: data
dataType: number
- tag: hasMore # fast+interactive ✓
type: variant
dataType: boolean
phase: fast+interactive
- tag: isLoading # fast+interactive ✓
type: variant
dataType: boolean
phase: fast+interactive
- tag: loadMoreButton # interactive ✓
type: interactive
elementType: HTMLButtonElement
Missing Elements
loadedItems- array for dynamically loaded items (phase: fast+interactive)loadedCount- count of currently loaded items (phase: fast+interactive)
Questions and Answers
Q1: Should we follow the exact pattern from category-page?
A: Yes. The pattern is proven and provides:
- Fast initial render (slow-phase items baked into HTML)
- Efficient client-side loading (only new items sent over the wire)
- Clear separation of concerns
Q2: Should items in loadedItems have the same structure as items?
A: Yes, both should use the same card structure. In the generator, we can inline the tags or reference a shared definition.
Q3: What about the totalCount - should it be slow or fast phase?
A: Keep it as slow (default). The total count is determined at build time and doesn't change during client-side interactions (unless we support real-time updates, which we don't).
Q4: Should we support sorting or filtering on the list?
A: No for now. Keep it simple - just pagination via "load more". Filtering/sorting can be added in a future iteration (like product-search has).
Design
Updated Contract Structure
name: {CollectionName}List
tags:
# Initial items (slow phase - build time)
- tag: items
type: sub-contract
repeated: true
trackBy: _id
description: Initial items (rendered server-side)
tags:
- {tag: _id, type: data, dataType: string}
- {tag: url, type: data, dataType: string, description: Full URL to item page}
- {tag: itemLink, type: interactive, elementType: HTMLAnchorElement}
# ... field-specific tags from schema
# Additional items (loaded on client via "load more")
- tag: loadedItems
type: sub-contract
repeated: true
trackBy: _id
phase: fast+interactive
description: Additional items loaded on the client
tags:
# Same structure as items
- {tag: _id, type: data, dataType: string}
- {tag: url, type: data, dataType: string}
- {tag: itemLink, type: interactive, elementType: HTMLAnchorElement}
# ... field-specific tags
# Metadata
- {tag: totalCount, type: data, dataType: number, description: Total items}
# Load more state
- {tag: hasMore, type: variant, dataType: boolean, phase: fast+interactive, description: More items available}
- {tag: isLoading, type: variant, dataType: boolean, phase: fast+interactive, description: Loading state}
- {tag: loadedCount, type: data, dataType: number, phase: fast+interactive, description: Items currently loaded}
- {tag: loadMoreButton, type: interactive, elementType: HTMLButtonElement, description: Load more trigger}
# Category (if configured)
# ... existing category sub-contract
# Breadcrumbs
# ... existing breadcrumbs sub-contract
Component Changes
Slow Render Phase
async function renderSlowlyChanging(
props: PageProps & ListPageParams & DynamicContractProps<WixDataMetadata>,
wixData: WixDataService,
) {
// ... existing category logic ...
// Query first page of items
const result = await query.limit(PAGE_SIZE).find();
// Map items to view state
const items = result.items.map((item) => ({
_id: item._id!,
url: `${config.pathPrefix}/${item.data?.[config.slugField] || item._id}`,
...item.data,
}));
return Pipeline.ok({
items, // Slow phase items
totalCount: result.totalCount || items.length,
category: categoryData,
breadcrumbs,
}).toPhaseOutput((data) => ({
viewState: data,
carryForward: {
collectionId,
categoryId: data.categoryId,
nextCursor: result.cursors?.next || null,
totalCount: data.totalCount,
},
}));
}
Fast Render Phase
async function renderFastChanging(
props: PageProps & ListPageParams & DynamicContractProps<WixDataMetadata>,
slowCarryForward: ListSlowCarryForward,
wixData: WixDataService,
) {
return Pipeline.ok({
loadedItems: [], // Empty initially
hasMore: slowCarryForward.nextCursor !== null,
isLoading: false,
loadedCount: 0, // NEW: track loaded count
}).toPhaseOutput((viewState) => ({
viewState,
carryForward: {
collectionId: slowCarryForward.collectionId,
categoryId: slowCarryForward.categoryId,
nextCursor: slowCarryForward.nextCursor,
},
}));
}
Interactive Phase
function ListInteractive(
_props: Props<PageProps & ListPageParams>,
refs: any,
viewStateSignals: Signals<ListFastViewState>,
fastCarryForward: ListFastCarryForward,
wixDataContext: WixDataContext,
) {
const {
hasMore: [hasMore, setHasMore],
isLoading: [isLoading, setIsLoading],
loadedItems: [loadedItems, setLoadedItems], // NEW
loadedCount: [loadedCount, setLoadedCount], // NEW
} = viewStateSignals;
let currentCursor = fastCarryForward.nextCursor;
refs.loadMoreButton?.onclick(async () => {
if (!currentCursor || isLoading()) return;
setIsLoading(true);
try {
const result = await wixDataContext.items
.queryDataItems({
dataCollectionId: fastCarryForward.collectionId,
})
.limit(PAGE_SIZE)
.skipTo(currentCursor)
.find();
// Map new items and append to loadedItems
const newItems = result.items.map((item) => ({
_id: item._id!,
url: `${config.pathPrefix}/${item.data?.[config.slugField] || item._id}`,
...item.data,
}));
setLoadedItems([...loadedItems(), ...newItems]);
setLoadedCount(loadedCount() + newItems.length);
setHasMore(result.hasNext?.() ?? false);
currentCursor = result.cursors?.next || null;
} catch (error) {
console.error('[wix-data] Failed to load more items:', error);
} finally {
setIsLoading(false);
}
});
return {
render: () => ({
loadedItems: loadedItems(),
hasMore: hasMore(),
isLoading: isLoading(),
loadedCount: loadedCount(),
}),
};
}
Generator Changes
Update list-contract-generator.ts:
function buildContract(schema: ProcessedSchema): string {
const tags: string[] = [];
// Initial items (slow phase - build time)
tags.push(buildItemsSubContract(schema, 'items', undefined)); // no phase = slow
// Additional items (fast+interactive - loaded on client)
tags.push(buildItemsSubContract(schema, 'loadedItems', 'fast+interactive'));
// Metadata (slow phase)
tags.push(dataTag('totalCount', 'number', 'Total items'));
// Load more state (fast+interactive)
tags.push(variantTag('hasMore', 'boolean', 'fast+interactive', 'More items available'));
tags.push(variantTag('isLoading', 'boolean', 'fast+interactive', 'Loading state'));
tags.push(dataTag('loadedCount', 'number', 'fast+interactive', 'Items currently loaded'));
tags.push(interactiveTag('loadMoreButton', 'HTMLButtonElement', 'Load more trigger'));
// ... rest of existing logic (category, breadcrumbs)
return `name: ${toPascalCase(schema.collectionId)}List
description: List page for ${schema.displayName || schema.collectionId}
tags:
${tags.join('\n')}`;
}
function buildItemsSubContract(schema: ProcessedSchema, tagName: string, phase?: string): string {
const cardTags: string[] = [
dataTag('_id', 'string', undefined, 6),
dataTag('url', 'string', 'Full URL to item page', 6),
interactiveTag('itemLink', 'HTMLAnchorElement', undefined, 6),
];
schema.fields.filter(isCardField).forEach((f) => {
const tag = fieldToTag(f, 6);
if (tag) cardTags.push(tag);
});
const phaseAttr = phase ? `\n phase: ${phase}` : '';
const description = phase
? 'Additional items loaded on the client'
: 'Initial items (rendered server-side)';
return ` - tag: ${tagName}
type: sub-contract
repeated: true
trackBy: _id${phaseAttr}
description: ${description}
tags:
${cardTags.join('\n')}`;
}
Template Usage Example
<!-- list page template -->
<section class="list-page">
<!-- Initial items (slow-rendered) -->
<article class="item-card" forEach="list.items" trackBy="_id">
<a href="{url}" ref="list.items.itemLink">
<h2>{title}</h2>
<p>{excerpt}</p>
</a>
</article>
<!-- Dynamically loaded items -->
<article class="item-card" forEach="list.loadedItems" trackBy="_id">
<a href="{url}" ref="list.loadedItems.itemLink">
<h2>{title}</h2>
<p>{excerpt}</p>
</a>
</article>
<!-- Load more button -->
<button
ref="list.loadMoreButton"
when="list.hasMore"
is="true"
class="{list.isLoading ? loading}"
>
{list.isLoading ? Loading... : Load More}
</button>
</section>
Implementation Plan
Phase 1: Update Contract Generator
- Modify
list-contract-generator.ts:- Add
loadedItemssub-contract withphase: fast+interactive - Add
loadedCountdata tag withphase: fast+interactive - Refactor
buildItemsSubContractto accept tag name and optional phase
- Add
Phase 2: Update Component
- Modify
collection-list.ts:- Update
ListSlowViewState- keep onlyitems - Update
ListFastViewState- addloadedItems,loadedCount - Update
renderSlowlyChanging- return only first page items - Update
renderFastChanging- return emptyloadedItems,loadedCount: 0 - Update
ListInteractive- append toloadedItems, updateloadedCount
- Update
Phase 3: Update contract-utils (if needed)
- Add helper for phase-aware data tags:
dataTagWithPhase(key, type, phase?, description?, indent?)
Phase 4: Update CMS Example Templates
- Update
recipes/page.jay-html:- Add
forEach="recipes.loadedItems"section afteritems - Both sections use same card markup
- Add
- Update
food-service-product-lines/page.jay-html:- Add
forEach="productLines.loadedItems"section afteritems - Both sections use same card markup
- Add
Phase 5: Testing & Verification
- Run
yarn devinwix/examples/cms - Navigate to
/recipesand/food-service-product-lines - Verify first page renders (view source shows baked-in content)
- Click "Load More" and verify additional items appear
- Verify loaded items NOT in initial HTML source
- Check browser console for any errors
Trade-offs
| Decision | Pros | Cons |
|---|---|---|
Separate items and loadedItems |
Clear phase separation, matches wix-stores pattern | Two arrays to render in template |
| Tags inline in both arrays | No linked contract complexity | Some duplication in generated YAML |
| No sorting/filtering | Simpler implementation | Limited functionality initially |
Verification Criteria
Technical Verification
- Contract generation:
itemshas no phase (slow),loadedItemshasphase: fast+interactive - Slow render: First page items rendered at build time
- Fast render:
loadedItemsstarts empty,hasMoreset correctly - Interactive: Load more appends to
loadedItems, notitems - Template works: Both
forEach="items"andforEach="loadedItems"render correctly
CMS Example Verification
Use the wix/examples/cms project to verify the implementation works end-to-end.
Setup
cd wix/examples/cms
yarn dev
Test Cases
Test 1: Recipes List Page - Initial Render
- Navigate to
http://localhost:3000/recipes - Verify first page of recipes is rendered (check page source - items should be in HTML)
- Count displayed recipe cards - should match
PAGE_SIZE(e.g., 20) - "Load More Recipes" button visible if more items exist
Test 2: Recipes List Page - Load More
- Click "Load More Recipes" button
- Loading spinner appears
- Additional recipe cards append below the first page
- Button text updates or hides if no more items
- Verify new items are NOT in initial HTML (dynamic render)
Test 3: Product Lines List Page - Same Tests
- Navigate to
http://localhost:3000/food-service-product-lines - Repeat Test 1 and Test 2 for product lines
Test 4: View Page Source (Slow Render Verification)
- View page source for
/recipes - First page recipe cards should have actual content (titles, images) baked in
loadedItemssection should be empty in the HTMLhasMoreshould be set based on whether more items exist
Test 5: Browser Dev Tools (Phase Verification)
- Open Network tab
- Click "Load More"
- Verify action request is made
- Response contains only the next page items (not full page)
Template Update Required
Update recipes/page.jay-html to include both arrays:
<!-- Initial items (slow-rendered) -->
<article class="recipe-card" forEach="recipes.items" trackBy="_id">...</article>
<!-- Dynamically loaded items -->
<article class="recipe-card" forEach="recipes.loadedItems" trackBy="_id">...</article>
Same update needed for food-service-product-lines/page.jay-html.
Related Design Logs
- Jay #50: Rendering phases in contracts
- Jay #75: Slow rendering jay-html to jay-html
- Wix #03: Category pages (reference implementation)
- Wix #05: Wix Data plugin (original design)
Log Methodology Note
Note: These design logs are written primarily for AI agents as part of the Design Log methodology and made accessible here for human readers. The language and structure are optimized for machine consumption — expect precise, specification-style prose rather than narrative documentation.