Browse Source

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
pull/159/head
Matthew Raymer 3 weeks ago
parent
commit
676cd6a537
  1. 420
      src/views/HomeView.vue

420
src/views/HomeView.vue

@ -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&hellip;
</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&hellip;
</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,12 +877,57 @@ 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);
};
}
/**
@ -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>

Loading…
Cancel
Save