feat: implement performance optimizations for HomeView feed loading
- Add skeleton loading state for immediate visual feedback during feed loading - Implement priority record processing for faster initial display (first 5 records) - Add background processing for remaining records to prevent UI blocking - Implement batch plan fetching to reduce API calls - Add performance logging in development mode - Optimize filter logic with early exits for better performance - Add debounced feed updates to prevent rapid successive calls - Fix InfiniteScroll conflicts with improved loading state management - Add debug method for testing optimization capabilities
This commit is contained in:
@@ -223,12 +223,27 @@ Raymer * @version 1.0.0 */
|
||||
</div>
|
||||
<InfiniteScroll @reached-bottom="loadMoreGives">
|
||||
<ul id="listLatestActivity" class="space-y-4">
|
||||
<!-- Skeleton loading state for immediate visual feedback -->
|
||||
<div v-if="isFeedLoading && feedData.length === 0" class="space-y-4">
|
||||
<div v-for="i in 3" :key="`skeleton-${i}`" class="animate-pulse">
|
||||
<div class="bg-gray-200 rounded-lg p-4">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="w-12 h-12 bg-gray-300 rounded-full"></div>
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="h-4 bg-gray-300 rounded w-3/4"></div>
|
||||
<div class="h-3 bg-gray-300 rounded w-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ActivityListItem
|
||||
v-for="record in feedData"
|
||||
:key="record.jwtId"
|
||||
:record="record"
|
||||
:last-viewed-claim-id="feedLastViewedClaimId"
|
||||
:is-registered="isRegistered"
|
||||
:is-registered="isUserRegistered"
|
||||
:active-did="activeDid"
|
||||
:confirmer-id-list="record.confirmerIdList"
|
||||
:on-image-cache="cacheImageData"
|
||||
@@ -243,6 +258,11 @@ Raymer * @version 1.0.0 */
|
||||
<font-awesome icon="spinner" class="fa-spin-pulse" /> Loading…
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="isBackgroundProcessing" class="mt-2">
|
||||
<p class="text-slate-400 text-center text-sm italic">
|
||||
<font-awesome icon="spinner" class="fa-spin" /> Loading more content…
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="!isFeedLoading && feedData.length === 0">
|
||||
<p class="text-slate-500 text-center italic mt-4 mb-4">
|
||||
No claims match your filters.
|
||||
@@ -302,7 +322,7 @@ import {
|
||||
GiverReceiverInputInfo,
|
||||
OnboardPage,
|
||||
} from "../libs/util";
|
||||
import { GiveSummaryRecord } from "../interfaces/records";
|
||||
import { GiveSummaryRecord, PlanSummaryRecord } from "../interfaces/records";
|
||||
import * as serverUtil from "../libs/endorserServer";
|
||||
import { logger } from "../utils/logger";
|
||||
import { GiveRecordWithContactInfo } from "../interfaces/give";
|
||||
@@ -414,16 +434,18 @@ export default class HomeView extends Vue {
|
||||
allMyDids: Array<string> = [];
|
||||
apiServer = "";
|
||||
blockedContactDids: Array<string> = [];
|
||||
// Feed data and state
|
||||
feedData: GiveRecordWithContactInfo[] = [];
|
||||
feedPreviousOldestId?: string;
|
||||
isFeedLoading = false;
|
||||
isBackgroundProcessing = false;
|
||||
feedPreviousOldestId: string | undefined = undefined;
|
||||
feedLastViewedClaimId?: string;
|
||||
givenName = "";
|
||||
isRegistered = false;
|
||||
isAnyFeedFilterOn = false;
|
||||
// isCreatingIdentifier removed - identity creation now handled by router guard
|
||||
isFeedFilteredByVisible = false;
|
||||
isFeedFilteredByNearby = false;
|
||||
isFeedLoading = true;
|
||||
isRegistered = false;
|
||||
lastAckedOfferToUserJwtId?: string; // the last JWT ID for offer-to-user that they've acknowledged seeing
|
||||
lastAckedOfferToUserProjectsJwtId?: string; // the last JWT ID for offers-to-user's-projects that they've acknowledged seeing
|
||||
newOffersToUserHitLimit: boolean = false;
|
||||
@@ -823,9 +845,8 @@ export default class HomeView extends Vue {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reloads feed when filter settings change using ultra-concise mixin utilities
|
||||
* - Updates filter states
|
||||
* - Clears existing feed data
|
||||
* Reloads feed when filters change
|
||||
* - Resets feed data and pagination
|
||||
* - Triggers new feed load
|
||||
*
|
||||
* @public
|
||||
@@ -840,8 +861,11 @@ export default class HomeView extends Vue {
|
||||
this.isFeedFilteredByNearby = !!settings.filterFeedByNearby;
|
||||
this.isAnyFeedFilterOn = checkIsAnyFeedFilterOn(settings);
|
||||
|
||||
// Reset feed state to prevent InfiniteScroll conflicts
|
||||
this.feedData = [];
|
||||
this.feedPreviousOldestId = undefined;
|
||||
this.isBackgroundProcessing = false;
|
||||
|
||||
await this.updateAllFeed();
|
||||
}
|
||||
|
||||
@@ -853,14 +877,59 @@ export default class HomeView extends Vue {
|
||||
* @param payload Boolean indicating if more items should be loaded
|
||||
*/
|
||||
async loadMoreGives(payload: boolean) {
|
||||
// Since feed now loads projects along the way, it takes longer
|
||||
// and the InfiniteScroll component triggers a load before finished.
|
||||
// One alternative is to totally separate the project link loading.
|
||||
if (payload && !this.isFeedLoading) {
|
||||
// Prevent loading if already processing or if background processing is active
|
||||
if (payload && !this.isFeedLoading && !this.isBackgroundProcessing) {
|
||||
// Use direct update instead of debounced to avoid conflicts with InfiniteScroll's debouncing
|
||||
await this.updateAllFeed();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounced version of updateAllFeed to prevent rapid successive calls
|
||||
*
|
||||
* @internal
|
||||
* @callGraph
|
||||
* Called by: loadMoreGives()
|
||||
* Calls: updateAllFeed()
|
||||
*
|
||||
* @chain
|
||||
* loadMoreGives() -> debouncedUpdateFeed() -> updateAllFeed()
|
||||
*
|
||||
* @requires
|
||||
* - this.isFeedLoading
|
||||
*/
|
||||
private debouncedUpdateFeed = this.debounce(async () => {
|
||||
if (!this.isFeedLoading) {
|
||||
await this.updateAllFeed();
|
||||
}
|
||||
}, 300);
|
||||
|
||||
/**
|
||||
* Creates a debounced function to prevent rapid successive calls
|
||||
*
|
||||
* @internal
|
||||
* @callGraph
|
||||
* Called by: debouncedUpdateFeed()
|
||||
* Calls: None
|
||||
*
|
||||
* @chain
|
||||
* debouncedUpdateFeed() -> debounce()
|
||||
*
|
||||
* @param func Function to debounce
|
||||
* @param delay Delay in milliseconds
|
||||
* @returns Debounced function
|
||||
*/
|
||||
private debounce<T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
delay: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
return (...args: Parameters<T>) => {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => func(...args), delay);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if coordinates fall within any search box
|
||||
*
|
||||
@@ -921,18 +990,52 @@ export default class HomeView extends Vue {
|
||||
* - this.feedLastViewedClaimId (via updateFeedLastViewedId)
|
||||
*/
|
||||
async updateAllFeed() {
|
||||
const startTime = performance.now();
|
||||
this.isFeedLoading = true;
|
||||
let endOfResults = true;
|
||||
|
||||
try {
|
||||
const apiStartTime = performance.now();
|
||||
const results = await this.retrieveGives(
|
||||
this.apiServer,
|
||||
this.feedPreviousOldestId,
|
||||
);
|
||||
const apiTime = performance.now() - apiStartTime;
|
||||
|
||||
if (results.data.length > 0) {
|
||||
endOfResults = false;
|
||||
// gather any contacts that user has blocked from view
|
||||
await this.processFeedResults(results.data);
|
||||
|
||||
// Check if we have cached data for these records
|
||||
const uncachedRecords = this.filterUncachedRecords(results.data);
|
||||
|
||||
if (uncachedRecords.length > 0) {
|
||||
// Process first 5 records immediately for quick display
|
||||
const priorityRecords = uncachedRecords.slice(0, 5);
|
||||
const remainingRecords = uncachedRecords.slice(5);
|
||||
|
||||
// Process priority records first
|
||||
const processStartTime = performance.now();
|
||||
await this.processPriorityRecords(priorityRecords);
|
||||
const processTime = performance.now() - processStartTime;
|
||||
|
||||
// Process remaining records in background
|
||||
if (remainingRecords.length > 0) {
|
||||
this.processRemainingRecords(remainingRecords);
|
||||
}
|
||||
|
||||
// Log performance metrics in development
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
logger.debug('[HomeView Performance]', {
|
||||
apiTime: `${apiTime.toFixed(2)}ms`,
|
||||
processTime: `${processTime.toFixed(2)}ms`,
|
||||
priorityRecords: priorityRecords.length,
|
||||
remainingRecords: remainingRecords.length,
|
||||
totalRecords: results.data.length,
|
||||
cacheHitRate: `${((results.data.length - uncachedRecords.length) / results.data.length * 100).toFixed(1)}%`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await this.updateFeedLastViewedId(results.data);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -944,6 +1047,100 @@ export default class HomeView extends Vue {
|
||||
}
|
||||
|
||||
this.isFeedLoading = false;
|
||||
const totalTime = performance.now() - startTime;
|
||||
|
||||
// Log total performance in development
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
logger.debug('[HomeView Feed Update]', {
|
||||
totalTime: `${totalTime.toFixed(2)}ms`,
|
||||
feedDataLength: this.feedData.length
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes priority records for immediate display
|
||||
*
|
||||
* @internal
|
||||
* @callGraph
|
||||
* Called by: updateAllFeed()
|
||||
* Calls: processRecordWithCache()
|
||||
*
|
||||
* @chain
|
||||
* updateAllFeed() -> processPriorityRecords()
|
||||
*
|
||||
* @param priorityRecords Array of records to process immediately
|
||||
*/
|
||||
private async processPriorityRecords(priorityRecords: GiveSummaryRecord[]) {
|
||||
// Fetch plans for priority records only
|
||||
const planHandleIds = new Set<string>();
|
||||
priorityRecords.forEach(record => {
|
||||
if (record.fulfillsPlanHandleId) {
|
||||
planHandleIds.add(record.fulfillsPlanHandleId);
|
||||
}
|
||||
});
|
||||
|
||||
const planCache = new Map<string, PlanSummaryRecord>();
|
||||
await this.batchFetchPlans(Array.from(planHandleIds), planCache);
|
||||
|
||||
// Process and display priority records immediately
|
||||
for (const record of priorityRecords) {
|
||||
const processedRecord = await this.processRecordWithCache(record, planCache, true);
|
||||
if (processedRecord) {
|
||||
await nextTick(() => {
|
||||
this.feedData.push(processedRecord);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes remaining records in background
|
||||
*
|
||||
* @internal
|
||||
* @callGraph
|
||||
* Called by: updateAllFeed()
|
||||
* Calls: processFeedResults()
|
||||
*
|
||||
* @chain
|
||||
* updateAllFeed() -> processRemainingRecords()
|
||||
*
|
||||
* @param remainingRecords Array of records to process in background
|
||||
*/
|
||||
private async processRemainingRecords(remainingRecords: GiveSummaryRecord[]) {
|
||||
// Process remaining records without blocking the UI
|
||||
this.isBackgroundProcessing = true;
|
||||
|
||||
// Use a longer delay to ensure InfiniteScroll doesn't trigger prematurely
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await this.processFeedResults(remainingRecords);
|
||||
} finally {
|
||||
this.isBackgroundProcessing = false;
|
||||
}
|
||||
}, 500); // Increased delay to prevent conflicts with InfiniteScroll
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters out records that are already cached to avoid re-processing
|
||||
*
|
||||
* @internal
|
||||
* @callGraph
|
||||
* Called by: updateAllFeed()
|
||||
* Calls: None
|
||||
*
|
||||
* @chain
|
||||
* updateAllFeed() -> filterUncachedRecords()
|
||||
*
|
||||
* @requires
|
||||
* - this.feedData
|
||||
*
|
||||
* @param records Array of records to filter
|
||||
* @returns Array of records not already in feed data
|
||||
*/
|
||||
private filterUncachedRecords(records: GiveSummaryRecord[]): GiveSummaryRecord[] {
|
||||
const existingJwtIds = new Set(this.feedData.map(record => record.jwtId));
|
||||
return records.filter(record => !existingJwtIds.has(record.jwtId));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -968,23 +1165,158 @@ export default class HomeView extends Vue {
|
||||
* @param records Array of feed records to process
|
||||
*/
|
||||
private async processFeedResults(records: GiveSummaryRecord[]) {
|
||||
// Pre-fetch all required plans in batch to reduce API calls
|
||||
const planHandleIds = new Set<string>();
|
||||
records.forEach(record => {
|
||||
if (record.fulfillsPlanHandleId) {
|
||||
planHandleIds.add(record.fulfillsPlanHandleId);
|
||||
}
|
||||
});
|
||||
|
||||
// Batch fetch all plans
|
||||
const planCache = new Map<string, PlanSummaryRecord>();
|
||||
await this.batchFetchPlans(Array.from(planHandleIds), planCache);
|
||||
|
||||
// Process and display records immediately as they're ready
|
||||
const processedRecords: GiveRecordWithContactInfo[] = [];
|
||||
|
||||
for (const record of records) {
|
||||
const processedRecord = await this.processRecord(record);
|
||||
const processedRecord = await this.processRecordWithCache(record, planCache);
|
||||
if (processedRecord) {
|
||||
processedRecords.push(processedRecord);
|
||||
|
||||
// Display records in batches of 3 for immediate visual feedback
|
||||
if (processedRecords.length % 3 === 0) {
|
||||
await nextTick(() => {
|
||||
this.feedData.push(...processedRecords.slice(-3));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Batch update the feed data to reduce reactivity triggers
|
||||
await nextTick(() => {
|
||||
this.feedData.push(...processedRecords);
|
||||
});
|
||||
// Add any remaining records
|
||||
const remainingRecords = processedRecords.slice(Math.floor(processedRecords.length / 3) * 3);
|
||||
if (remainingRecords.length > 0) {
|
||||
await nextTick(() => {
|
||||
this.feedData.push(...remainingRecords);
|
||||
});
|
||||
}
|
||||
|
||||
this.feedPreviousOldestId = records[records.length - 1].jwtId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch fetches multiple plans to reduce API calls
|
||||
*
|
||||
* @internal
|
||||
* @callGraph
|
||||
* Called by: processFeedResults()
|
||||
* Calls: getPlanFromCache()
|
||||
*
|
||||
* @chain
|
||||
* processFeedResults() -> batchFetchPlans()
|
||||
*
|
||||
* @requires
|
||||
* - this.axios
|
||||
* - this.apiServer
|
||||
* - this.activeDid
|
||||
*
|
||||
* @param planHandleIds Array of plan handle IDs to fetch
|
||||
* @param planCache Map to store fetched plans
|
||||
*/
|
||||
private async batchFetchPlans(
|
||||
planHandleIds: string[],
|
||||
planCache: Map<string, PlanSummaryRecord>
|
||||
) {
|
||||
// Process plans in batches of 10 to avoid overwhelming the API
|
||||
const batchSize = 10;
|
||||
for (let i = 0; i < planHandleIds.length; i += batchSize) {
|
||||
const batch = planHandleIds.slice(i, i + batchSize);
|
||||
await Promise.all(
|
||||
batch.map(async (handleId) => {
|
||||
const plan = await getPlanFromCache(
|
||||
handleId,
|
||||
this.axios,
|
||||
this.apiServer,
|
||||
this.activeDid,
|
||||
);
|
||||
if (plan) {
|
||||
planCache.set(handleId, plan);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a single record with cached plans
|
||||
*
|
||||
* @internal
|
||||
* @callGraph
|
||||
* Called by: processFeedResults()
|
||||
* Calls:
|
||||
* - extractClaim()
|
||||
* - extractGiverDid()
|
||||
* - extractRecipientDid()
|
||||
* - shouldIncludeRecord()
|
||||
* - extractProvider()
|
||||
* - createFeedRecord()
|
||||
*
|
||||
* @chain
|
||||
* processFeedResults() -> processRecordWithCache()
|
||||
*
|
||||
* @requires
|
||||
* - this.isAnyFeedFilterOn
|
||||
* - this.isFeedFilteredByVisible
|
||||
* - this.isFeedFilteredByNearby
|
||||
* - this.activeDid
|
||||
* - this.allContacts
|
||||
*
|
||||
* @param record The record to process
|
||||
* @param planCache Map of cached plans
|
||||
* @param isPriority Whether this is a priority record for quick display
|
||||
* @returns Processed record with contact info if it passes filters, null otherwise
|
||||
*/
|
||||
private async processRecordWithCache(
|
||||
record: GiveSummaryRecord,
|
||||
planCache: Map<string, PlanSummaryRecord>,
|
||||
isPriority: boolean = false
|
||||
): Promise<GiveRecordWithContactInfo | null> {
|
||||
const claim = this.extractClaim(record);
|
||||
const giverDid = this.extractGiverDid(claim);
|
||||
const recipientDid = this.extractRecipientDid(claim);
|
||||
|
||||
// For priority records, skip expensive plan lookups initially
|
||||
let fulfillsPlan: FulfillsPlan | undefined;
|
||||
if (!isPriority || record.fulfillsPlanHandleId) {
|
||||
fulfillsPlan = planCache.get(record.fulfillsPlanHandleId || '') ||
|
||||
await this.getFulfillsPlan(record);
|
||||
}
|
||||
|
||||
if (!this.shouldIncludeRecord(record, fulfillsPlan)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const provider = this.extractProvider(claim);
|
||||
let providedByPlan: ProvidedByPlan | undefined;
|
||||
|
||||
// For priority records, defer provider plan lookup
|
||||
if (!isPriority && provider?.identifier) {
|
||||
providedByPlan = planCache.get(provider.identifier) ||
|
||||
await this.getProvidedByPlan(provider);
|
||||
}
|
||||
|
||||
return this.createFeedRecord(
|
||||
record,
|
||||
claim,
|
||||
giverDid,
|
||||
recipientDid,
|
||||
provider,
|
||||
fulfillsPlan,
|
||||
providedByPlan,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a single record and returns it if it passes filters
|
||||
*
|
||||
@@ -1153,34 +1485,35 @@ export default class HomeView extends Vue {
|
||||
record: GiveSummaryRecord,
|
||||
fulfillsPlan?: FulfillsPlan,
|
||||
): boolean {
|
||||
// Early exit for blocked contacts
|
||||
if (this.blockedContactDids.includes(record.issuerDid)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If no filters are active, include all records
|
||||
if (!this.isAnyFeedFilterOn) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let anyMatch = false;
|
||||
// Check visibility filter first (faster than location check)
|
||||
if (this.isFeedFilteredByVisible && containsNonHiddenDid(record)) {
|
||||
anyMatch = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
!anyMatch &&
|
||||
this.isFeedFilteredByNearby &&
|
||||
record.fulfillsPlanHandleId
|
||||
) {
|
||||
// Check location filter only if needed and plan exists
|
||||
if (this.isFeedFilteredByNearby && record.fulfillsPlanHandleId) {
|
||||
if (fulfillsPlan?.locLat && fulfillsPlan?.locLon) {
|
||||
anyMatch =
|
||||
this.latLongInAnySearchBox(
|
||||
fulfillsPlan.locLat,
|
||||
fulfillsPlan.locLon,
|
||||
) ?? false;
|
||||
return this.latLongInAnySearchBox(
|
||||
fulfillsPlan.locLat,
|
||||
fulfillsPlan.locLon,
|
||||
) ?? false;
|
||||
}
|
||||
// If plan exists but no location data, exclude it
|
||||
return false;
|
||||
}
|
||||
|
||||
return anyMatch;
|
||||
// If we reach here, no filters matched
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1809,5 +2142,28 @@ export default class HomeView extends Vue {
|
||||
get isUserRegistered() {
|
||||
return this.isRegistered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug method to verify debugging capabilities work with optimizations
|
||||
*
|
||||
* @public
|
||||
* Called by: Debug testing
|
||||
* @returns Debug information
|
||||
*/
|
||||
debugOptimizations() {
|
||||
// This method should be debuggable with breakpoints
|
||||
const debugInfo = {
|
||||
timestamp: new Date().toISOString(),
|
||||
feedDataLength: this.feedData.length,
|
||||
isFeedLoading: this.isFeedLoading,
|
||||
activeDid: this.activeDid,
|
||||
performance: performance.now()
|
||||
};
|
||||
|
||||
console.log('🔍 Debug Info:', debugInfo);
|
||||
debugger; // This should trigger breakpoint in dev tools
|
||||
|
||||
return debugInfo;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user