@xhub-short/core 0.1.0-beta.14 → 0.1.0-beta.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/index.d.ts +107 -57
  2. package/dist/index.js +233 -140
  3. package/package.json +2 -2
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { INetworkAdapter, IVideoLoader, IPosterLoader, ILogger, VideoSource, VideoItem, IDataSource, IStorage, PrefetchCacheData, IAnalytics, ISessionStorage, SessionSnapshot, IInteraction, CommentItem, ReplyItem, ICommentAdapter, InternalLogger, CommentAuthor, PlaylistData, ContentItem, PlaylistSummary, IPlaylistDataSource } from '@xhub-short/contracts';
1
+ import { INetworkAdapter, IVideoLoader, IPosterLoader, ILogger, VideoSource, ContentItem, IDataSource, IStorage, VideoItem, PrefetchCacheData, IAnalytics, ISessionStorage, SessionSnapshot, IInteraction, CommentItem, ReplyItem, ICommentAdapter, InternalLogger, CommentAuthor, PlaylistData, PlaylistSummary, IPlaylistDataSource } from '@xhub-short/contracts';
2
2
  export { SessionSnapshot } from '@xhub-short/contracts';
3
3
  import { StoreApi } from 'zustand/vanilla';
4
4
 
@@ -313,9 +313,9 @@ declare class ResourceGovernor {
313
313
  * Feed state for zustand store
314
314
  */
315
315
  interface FeedState {
316
- /** Normalized video data - Map for O(1) lookup */
317
- itemsById: Map<string, VideoItem>;
318
- /** Ordered list of video IDs for rendering */
316
+ /** Normalized content data - Map for O(1) lookup */
317
+ itemsById: Map<string, ContentItem>;
318
+ /** Ordered list of content IDs for rendering */
319
319
  displayOrder: string[];
320
320
  /** Initial loading state */
321
321
  loading: boolean;
@@ -360,8 +360,8 @@ interface FeedConfig {
360
360
  /** Whether to enable SWR pattern (default: true) */
361
361
  enableSWR?: boolean;
362
362
  /**
363
- * Maximum number of videos to keep in memory cache
364
- * When exceeded, older videos will be evicted (LRU policy)
363
+ * Maximum number of items to keep in memory cache
364
+ * When exceeded, older items will be evicted (LRU policy)
365
365
  * @default 100
366
366
  */
367
367
  maxCacheSize?: number;
@@ -399,11 +399,14 @@ interface PrefetchCacheConfig {
399
399
  */
400
400
  enabled: boolean;
401
401
  /**
402
- * Maximum number of videos to cache
402
+ * Maximum number of items to cache
403
403
  * Higher = more instant content, but more storage
404
- * @default 10
405
404
  */
406
- maxVideos: number;
405
+ maxItems: number;
406
+ /**
407
+ * @deprecated Use maxItems instead. Will be removed in v3.0
408
+ */
409
+ maxVideos?: number;
407
410
  /**
408
411
  * Storage key for prefetch cache
409
412
  * @default 'sv-prefetch-cache'
@@ -411,8 +414,8 @@ interface PrefetchCacheConfig {
411
414
  storageKey: string;
412
415
  /**
413
416
  * Enable dynamic cache eviction
414
- * When user scrolls past cached videos, remove them from cache
415
- * Prevents user from rewatching same videos on reload
417
+ * When user scrolls past cached items, remove them from cache
418
+ * Prevents user from rewatching same items on reload
416
419
  * @default true
417
420
  */
418
421
  enableDynamicEviction: boolean;
@@ -429,7 +432,7 @@ interface PrefetchCacheConfig {
429
432
  declare const DEFAULT_PREFETCH_CACHE_CONFIG: PrefetchCacheConfig;
430
433
 
431
434
  /**
432
- * FeedManager - Manages video feed data with zustand/vanilla store
435
+ * FeedManager - Manages content feed data with zustand/vanilla store
433
436
  *
434
437
  * Features:
435
438
  * - Data normalization (Map for O(1) lookup + ordered IDs)
@@ -478,10 +481,16 @@ declare class FeedManager {
478
481
  */
479
482
  private accessOrder;
480
483
  /**
481
- * Track videos that have already triggered predictive preload
484
+ * Track items that have already triggered predictive preload
482
485
  * to avoid duplicate requests.
483
486
  */
484
- private preloadedVideoIds;
487
+ private preloadedItemIds;
488
+ /**
489
+ * Recommend feed snapshot — preserved when switching to playlist mode.
490
+ * Stored here (not in React refs) because FeedManager is a singleton
491
+ * that survives component remounts caused by conditional rendering.
492
+ */
493
+ private recommendSnapshot;
485
494
  constructor(dataSource: IDataSource, config?: FeedConfig, storage?: IStorage, prefetchConfig?: Partial<PrefetchCacheConfig>, logger?: ILogger | undefined);
486
495
  /** Static memory cache for explicit prefetching */
487
496
  private static globalMemoryCache;
@@ -558,29 +567,37 @@ declare class FeedManager {
558
567
  /**
559
568
  * Handle playback progress and trigger predictive preloading
560
569
  *
561
- * @param videoId - ID of the currently playing video
570
+ * @param itemId - ID of the currently playing item
562
571
  * @param progress - Current playback progress (0-1)
563
572
  * @param governor - Resource governor to trigger preload
564
573
  * @param threshold - Progress threshold to trigger preload (default: 0.2)
565
574
  */
566
- handlePlaybackProgress(videoId: string, progress: number, governor: ResourceGovernor, threshold?: number): void;
575
+ handlePlaybackProgress(itemId: string, progress: number, governor: ResourceGovernor, threshold?: number): void;
576
+ getItem(id: string): ContentItem | undefined;
577
+ /**
578
+ * Get ordered list of items
579
+ */
580
+ getItems(): ContentItem[];
567
581
  /**
568
- * Get a video by ID
569
- * Also updates LRU access time for garbage collection
582
+ * Update an item in the feed (for optimistic updates)
583
+ */
584
+ updateItem(id: string, updates: Partial<ContentItem>): void;
585
+ /**
586
+ * Replace all items in the feed (e.g. for Playlist synchronization)
587
+ */
588
+ replaceItems(items: ContentItem[]): void;
589
+ /**
590
+ * @deprecated Use getItem instead
570
591
  */
571
592
  getVideo(id: string): VideoItem | undefined;
572
593
  /**
573
- * Get ordered list of videos
594
+ * @deprecated Use getItems instead
574
595
  */
575
596
  getVideos(): VideoItem[];
576
597
  /**
577
- * Update a video in the feed (for optimistic updates)
598
+ * @deprecated Use updateItem instead
578
599
  */
579
600
  updateVideo(id: string, updates: Partial<VideoItem>): void;
580
- /**
581
- * Replace all items in the feed (e.g. for Playlist synchronization)
582
- */
583
- replaceItems(items: VideoItem[]): void;
584
601
  /**
585
602
  * Remove an item from the feed
586
603
  *
@@ -593,10 +610,10 @@ declare class FeedManager {
593
610
  *
594
611
  * @example
595
612
  * ```typescript
596
- * // User reports a video
597
- * const wasRemoved = feedManager.removeItem(videoId);
613
+ * // User reports a content item
614
+ * const wasRemoved = feedManager.removeItem(itemId);
598
615
  * if (wasRemoved) {
599
- * // Navigate to next video
616
+ * // Navigate to next item
600
617
  * }
601
618
  * ```
602
619
  */
@@ -623,20 +640,37 @@ declare class FeedManager {
623
640
  * Used by LifecycleManager to restore state without API call.
624
641
  * This bypasses normal data flow for state restoration.
625
642
  *
626
- * @param items - Video items from snapshot
643
+ * @param items - Content items from snapshot
627
644
  * @param cursor - Pagination cursor from snapshot
628
645
  * @param options - Additional hydration options
629
646
  */
630
- hydrateFromSnapshot(items: VideoItem[], cursor: string | null, options?: {
647
+ hydrateFromSnapshot(items: ContentItem[], cursor: string | null, options?: {
631
648
  /** Whether to mark data as stale for background revalidation */
632
649
  markAsStale?: boolean;
633
650
  }): void;
651
+ /**
652
+ * Save the current recommend feed state before switching to playlist mode.
653
+ *
654
+ * @param activeIndex - Current scroll position (active item index)
655
+ */
656
+ saveRecommendSnapshot(activeIndex: number): void;
657
+ /**
658
+ * Restore the recommend feed state after exiting playlist mode.
659
+ * Delegates to hydrateFromSnapshot() internally to avoid duplicating hydration logic.
660
+ *
661
+ * @returns The saved activeIndex, or null if no snapshot was available
662
+ */
663
+ restoreRecommendSnapshot(): number | null;
664
+ /**
665
+ * Check if a recommend snapshot exists (for conditional logic in hooks)
666
+ */
667
+ hasRecommendSnapshot(): boolean;
634
668
  /**
635
669
  * Update prefetch cache with current feed tail
636
670
  * Called automatically after loadInitial() and loadMore()
637
671
  *
638
- * Strategy: Cache the LAST N videos (tail of feed)
639
- * These are videos user hasn't seen yet, perfect for instant display
672
+ * Strategy: Cache the LAST N items (tail of feed)
673
+ * These are items user hasn't seen yet, perfect for instant display
640
674
  */
641
675
  updatePrefetchCache(): void;
642
676
  /**
@@ -653,21 +687,37 @@ declare class FeedManager {
653
687
  * Marks data as stale to trigger background revalidation
654
688
  */
655
689
  hydrateFromPrefetchCache(cache: PrefetchCacheData): void;
690
+ /**
691
+ * Load prefetch cache synchronously (zero-flash optimization)
692
+ *
693
+ * Uses `storage.getSync()` for synchronous localStorage access.
694
+ * This allows hydrating the feed during React's synchronous render phase,
695
+ * preventing any flash of loading/empty state when cached data exists.
696
+ *
697
+ * Returns null if:
698
+ * - Prefetch cache is disabled
699
+ * - No storage adapter
700
+ * - Storage doesn't support sync reads (e.g., IndexedDB)
701
+ * - No cached data
702
+ *
703
+ * @returns Cached feed data or null
704
+ */
705
+ loadPrefetchCacheSync(): PrefetchCacheData | null;
656
706
  /**
657
707
  * Clear prefetch cache
658
708
  * Call when user logs out or data should be invalidated
659
709
  */
660
710
  clearPrefetchCache(): Promise<void>;
661
711
  /**
662
- * Evict videos that user has scrolled past from prefetch cache
712
+ * Evict items that user has scrolled past from prefetch cache
663
713
  * Called when user's focusedIndex changes
664
714
  *
665
- * Strategy: Remove all videos at or before current position
666
- * This ensures user doesn't rewatch videos on reload
715
+ * Strategy: Remove all items at or before current position
716
+ * This ensures user doesn't rewatch items on reload
667
717
  *
668
- * @param currentIndex - Current focused video index in feed
718
+ * @param currentIndex - Current focused item index in feed
669
719
  */
670
- evictViewedVideosFromCache(currentIndex: number): Promise<void>;
720
+ evictViewedItemsFromCache(currentIndex: number): Promise<void>;
671
721
  /**
672
722
  * Get prefetch cache configuration (for external access)
673
723
  */
@@ -677,15 +727,15 @@ declare class FeedManager {
677
727
  */
678
728
  private fetchWithRetry;
679
729
  /**
680
- * Add videos with deduplication
730
+ * Add items with deduplication
681
731
  * Triggers garbage collection if cache exceeds maxCacheSize
682
732
  */
683
- private addVideos;
733
+ private addItems;
684
734
  /**
685
- * Merge videos (for SWR revalidation)
686
- * Updates existing videos, adds new ones at the beginning
735
+ * Merge items (for SWR revalidation)
736
+ * Updates existing items, adds new ones at the beginning
687
737
  */
688
- private mergeVideos;
738
+ private mergeItems;
689
739
  /**
690
740
  * Handle and categorize errors
691
741
  */
@@ -702,9 +752,9 @@ declare class FeedManager {
702
752
  * Run garbage collection using LRU (Least Recently Used) policy
703
753
  *
704
754
  * When cache size exceeds maxCacheSize:
705
- * 1. Sort videos by last access time (oldest first)
706
- * 2. Evict oldest videos until cache is within limit
707
- * 3. Keep videos that are currently in viewport (most recent in displayOrder)
755
+ * 1. Sort items by last access time (oldest first)
756
+ * 2. Evict oldest items until cache is within limit
757
+ * 3. Keep items that are currently in viewport (most recent in displayOrder)
708
758
  *
709
759
  * @returns Number of evicted items
710
760
  */
@@ -1402,7 +1452,7 @@ declare class LifecycleManager {
1402
1452
  * @param data.restoreFrame - Captured video frame at playback position (only saved if restorePlaybackPosition is enabled)
1403
1453
  */
1404
1454
  saveSnapshot(data: {
1405
- items: VideoItem[];
1455
+ items: ContentItem[];
1406
1456
  cursor: string | null;
1407
1457
  focusedIndex: number;
1408
1458
  scrollPosition?: number;
@@ -1601,7 +1651,7 @@ declare class OptimisticManager {
1601
1651
  private readonly config;
1602
1652
  /** Interaction adapter */
1603
1653
  private readonly interaction?;
1604
- /** Feed manager for updating video state */
1654
+ /** Feed manager for updating item state */
1605
1655
  private readonly feedManager?;
1606
1656
  /** Logger adapter */
1607
1657
  private readonly logger?;
@@ -1609,25 +1659,25 @@ declare class OptimisticManager {
1609
1659
  private readonly eventListeners;
1610
1660
  /** Retry timer */
1611
1661
  private retryTimer;
1612
- /** Debounce timers for like/unlike per video */
1662
+ /** Debounce timers for like/unlike per item */
1613
1663
  private readonly likeDebounceTimers;
1614
- /** Intended like state while debouncing (videoId -> isLiked) */
1664
+ /** Intended like state while debouncing (itemId -> isLiked) */
1615
1665
  private readonly intendedLikeState;
1616
1666
  /** Debounce delay in ms */
1617
1667
  private readonly debounceDelay;
1618
1668
  constructor(config?: OptimisticConfig);
1619
1669
  /**
1620
- * Like a video with optimistic update
1670
+ * Like an item with optimistic update
1621
1671
  */
1622
- like(videoId: string): Promise<boolean>;
1672
+ like(itemId: string): Promise<boolean>;
1623
1673
  /**
1624
- * Unlike a video with optimistic update
1674
+ * Unlike an item with optimistic update
1625
1675
  */
1626
- unlike(videoId: string): Promise<boolean>;
1676
+ unlike(itemId: string): Promise<boolean>;
1627
1677
  /**
1628
1678
  * Toggle like state with DEBOUNCE
1629
1679
  */
1630
- toggleLike(videoId: string): void;
1680
+ toggleLike(itemId: string): void;
1631
1681
  /**
1632
1682
  * Execute API call after debounce delay
1633
1683
  */
@@ -1643,9 +1693,9 @@ declare class OptimisticManager {
1643
1693
  /**
1644
1694
  * Toggle follow state
1645
1695
  */
1646
- toggleFollow(videoId: string): Promise<boolean>;
1647
- follow(videoId: string): Promise<boolean>;
1648
- unfollow(videoId: string): Promise<boolean>;
1696
+ toggleFollow(itemId: string): Promise<boolean>;
1697
+ follow(itemId: string): Promise<boolean>;
1698
+ unfollow(itemId: string): Promise<boolean>;
1649
1699
  addEventListener(listener: OptimisticEventListener): () => void;
1650
1700
  removeEventListener(listener: OptimisticEventListener): void;
1651
1701
  /**
@@ -1653,7 +1703,7 @@ declare class OptimisticManager {
1653
1703
  */
1654
1704
  reset(): void;
1655
1705
  getPendingActions(): PendingAction[];
1656
- hasPendingAction(videoId: string, type?: ActionType): boolean;
1706
+ hasPendingAction(itemId: string, type?: ActionType): boolean;
1657
1707
  getFailedQueue(): PendingAction[];
1658
1708
  retryFailed(): Promise<void>;
1659
1709
  clearFailed(): void;
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- // ../../node_modules/.pnpm/zustand@5.0.9_@types+react@19.2.7_react@19.2.3/node_modules/zustand/esm/vanilla.mjs
1
+ // ../../node_modules/.pnpm/zustand@5.0.11_@types+react@19.2.14_react@19.2.4/node_modules/zustand/esm/vanilla.mjs
2
2
  var createStoreImpl = (createState) => {
3
3
  let state;
4
4
  const listeners = /* @__PURE__ */ new Set();
@@ -35,6 +35,7 @@ var DEFAULT_FEED_CONFIG = {
35
35
  };
36
36
  var DEFAULT_PREFETCH_CACHE_CONFIG = {
37
37
  enabled: true,
38
+ maxItems: 10,
38
39
  maxVideos: 10,
39
40
  storageKey: "sv-prefetch-cache",
40
41
  enableDynamicEviction: true,
@@ -70,10 +71,16 @@ var _FeedManager = class _FeedManager {
70
71
  */
71
72
  this.accessOrder = /* @__PURE__ */ new Map();
72
73
  /**
73
- * Track videos that have already triggered predictive preload
74
+ * Track items that have already triggered predictive preload
74
75
  * to avoid duplicate requests.
75
76
  */
76
- this.preloadedVideoIds = /* @__PURE__ */ new Set();
77
+ this.preloadedItemIds = /* @__PURE__ */ new Set();
78
+ /**
79
+ * Recommend feed snapshot — preserved when switching to playlist mode.
80
+ * Stored here (not in React refs) because FeedManager is a singleton
81
+ * that survives component remounts caused by conditional rendering.
82
+ */
83
+ this.recommendSnapshot = null;
77
84
  this.config = { ...DEFAULT_FEED_CONFIG, ...config };
78
85
  this.prefetchConfig = { ...DEFAULT_PREFETCH_CACHE_CONFIG, ...prefetchConfig };
79
86
  this.storage = storage ?? null;
@@ -240,7 +247,7 @@ var _FeedManager = class _FeedManager {
240
247
  }
241
248
  try {
242
249
  const response = await this.dataSource.fetchFeed();
243
- this.mergeVideos(response.items, true);
250
+ this.mergeItems(response.items, true);
244
251
  this.store.setState({
245
252
  cursor: response.nextCursor,
246
253
  hasMore: response.hasMore,
@@ -253,17 +260,17 @@ var _FeedManager = class _FeedManager {
253
260
  /**
254
261
  * Handle playback progress and trigger predictive preloading
255
262
  *
256
- * @param videoId - ID of the currently playing video
263
+ * @param itemId - ID of the currently playing item
257
264
  * @param progress - Current playback progress (0-1)
258
265
  * @param governor - Resource governor to trigger preload
259
266
  * @param threshold - Progress threshold to trigger preload (default: 0.2)
260
267
  */
261
- handlePlaybackProgress(videoId, progress, governor, threshold = 0.2) {
262
- if (this.preloadedVideoIds.has(videoId)) return;
268
+ handlePlaybackProgress(itemId, progress, governor, threshold = 0.2) {
269
+ if (this.preloadedItemIds.has(itemId)) return;
263
270
  if (progress >= threshold) {
264
- this.preloadedVideoIds.add(videoId);
271
+ this.preloadedItemIds.add(itemId);
265
272
  const state = this.store.getState();
266
- const currentIndex = state.displayOrder.indexOf(videoId);
273
+ const currentIndex = state.displayOrder.indexOf(itemId);
267
274
  if (currentIndex !== -1) {
268
275
  const nextIndices = [currentIndex + 1, currentIndex + 2].filter(
269
276
  (idx) => idx < state.displayOrder.length
@@ -277,28 +284,24 @@ var _FeedManager = class _FeedManager {
277
284
  }
278
285
  }
279
286
  }
280
- /**
281
- * Get a video by ID
282
- * Also updates LRU access time for garbage collection
283
- */
284
- getVideo(id) {
285
- const video = this.store.getState().itemsById.get(id);
286
- if (video) {
287
+ getItem(id) {
288
+ const item = this.store.getState().itemsById.get(id);
289
+ if (item) {
287
290
  this.accessOrder.set(id, Date.now());
288
291
  }
289
- return video;
292
+ return item;
290
293
  }
291
294
  /**
292
- * Get ordered list of videos
295
+ * Get ordered list of items
293
296
  */
294
- getVideos() {
297
+ getItems() {
295
298
  const state = this.store.getState();
296
299
  return state.displayOrder.map((id) => state.itemsById.get(id)).filter((v) => v !== void 0);
297
300
  }
298
301
  /**
299
- * Update a video in the feed (for optimistic updates)
302
+ * Update an item in the feed (for optimistic updates)
300
303
  */
301
- updateVideo(id, updates) {
304
+ updateItem(id, updates) {
302
305
  const state = this.store.getState();
303
306
  const existing = state.itemsById.get(id);
304
307
  if (existing) {
@@ -326,6 +329,24 @@ var _FeedManager = class _FeedManager {
326
329
  hasMore: false
327
330
  });
328
331
  }
332
+ /**
333
+ * @deprecated Use getItem instead
334
+ */
335
+ getVideo(id) {
336
+ return this.getItem(id);
337
+ }
338
+ /**
339
+ * @deprecated Use getItems instead
340
+ */
341
+ getVideos() {
342
+ return this.getItems();
343
+ }
344
+ /**
345
+ * @deprecated Use updateItem instead
346
+ */
347
+ updateVideo(id, updates) {
348
+ this.updateItem(id, updates);
349
+ }
329
350
  /**
330
351
  * Remove an item from the feed
331
352
  *
@@ -338,10 +359,10 @@ var _FeedManager = class _FeedManager {
338
359
  *
339
360
  * @example
340
361
  * ```typescript
341
- * // User reports a video
342
- * const wasRemoved = feedManager.removeItem(videoId);
362
+ * // User reports a content item
363
+ * const wasRemoved = feedManager.removeItem(itemId);
343
364
  * if (wasRemoved) {
344
- * // Navigate to next video
365
+ * // Navigate to next item
345
366
  * }
346
367
  * ```
347
368
  */
@@ -375,7 +396,7 @@ var _FeedManager = class _FeedManager {
375
396
  this.cancelPendingRequests();
376
397
  this.inFlightRequests.clear();
377
398
  this.accessOrder.clear();
378
- this.preloadedVideoIds.clear();
399
+ this.preloadedItemIds.clear();
379
400
  this.store.setState(createInitialState());
380
401
  }
381
402
  /**
@@ -402,7 +423,7 @@ var _FeedManager = class _FeedManager {
402
423
  * Used by LifecycleManager to restore state without API call.
403
424
  * This bypasses normal data flow for state restoration.
404
425
  *
405
- * @param items - Video items from snapshot
426
+ * @param items - Content items from snapshot
406
427
  * @param cursor - Pagination cursor from snapshot
407
428
  * @param options - Additional hydration options
408
429
  */
@@ -413,10 +434,10 @@ var _FeedManager = class _FeedManager {
413
434
  const now = Date.now();
414
435
  const newItemsById = /* @__PURE__ */ new Map();
415
436
  const newDisplayOrder = [];
416
- for (const video of items) {
417
- newItemsById.set(video.id, video);
418
- newDisplayOrder.push(video.id);
419
- this.accessOrder.set(video.id, now);
437
+ for (const item of items) {
438
+ newItemsById.set(item.id, item);
439
+ newDisplayOrder.push(item.id);
440
+ this.accessOrder.set(item.id, now);
420
441
  }
421
442
  this.store.setState({
422
443
  itemsById: newItemsById,
@@ -431,26 +452,67 @@ var _FeedManager = class _FeedManager {
431
452
  });
432
453
  }
433
454
  // ═══════════════════════════════════════════════════════════════
455
+ // RECOMMEND SNAPSHOT (for playlist ↔ recommend switching)
456
+ // ═══════════════════════════════════════════════════════════════
457
+ /**
458
+ * Save the current recommend feed state before switching to playlist mode.
459
+ *
460
+ * @param activeIndex - Current scroll position (active item index)
461
+ */
462
+ saveRecommendSnapshot(activeIndex) {
463
+ this.recommendSnapshot = {
464
+ items: this.getItems(),
465
+ cursor: this.store.getState().cursor,
466
+ hasMore: this.store.getState().hasMore,
467
+ activeIndex
468
+ };
469
+ }
470
+ /**
471
+ * Restore the recommend feed state after exiting playlist mode.
472
+ * Delegates to hydrateFromSnapshot() internally to avoid duplicating hydration logic.
473
+ *
474
+ * @returns The saved activeIndex, or null if no snapshot was available
475
+ */
476
+ restoreRecommendSnapshot() {
477
+ const snapshot = this.recommendSnapshot;
478
+ if (!snapshot || snapshot.items.length === 0) {
479
+ this.recommendSnapshot = null;
480
+ return null;
481
+ }
482
+ this.hydrateFromSnapshot(snapshot.items, snapshot.cursor, {
483
+ markAsStale: false
484
+ });
485
+ const savedIndex = snapshot.activeIndex;
486
+ this.recommendSnapshot = null;
487
+ return savedIndex;
488
+ }
489
+ /**
490
+ * Check if a recommend snapshot exists (for conditional logic in hooks)
491
+ */
492
+ hasRecommendSnapshot() {
493
+ return this.recommendSnapshot !== null && this.recommendSnapshot.items.length > 0;
494
+ }
495
+ // ═══════════════════════════════════════════════════════════════
434
496
  // PREFETCH CACHE METHODS
435
497
  // ═══════════════════════════════════════════════════════════════
436
498
  /**
437
499
  * Update prefetch cache with current feed tail
438
500
  * Called automatically after loadInitial() and loadMore()
439
501
  *
440
- * Strategy: Cache the LAST N videos (tail of feed)
441
- * These are videos user hasn't seen yet, perfect for instant display
502
+ * Strategy: Cache the LAST N items (tail of feed)
503
+ * These are items user hasn't seen yet, perfect for instant display
442
504
  */
443
505
  updatePrefetchCache() {
444
506
  if (!this.prefetchConfig.enabled) return;
445
507
  if (!this.storage) return;
446
508
  const state = this.store.getState();
447
- const allVideos = this.getVideos();
448
- if (allVideos.length < this.prefetchConfig.maxVideos) {
509
+ const allItems = this.getItems();
510
+ if (allItems.length < this.prefetchConfig.maxItems) {
449
511
  return;
450
512
  }
451
- const tailVideos = allVideos.slice(-this.prefetchConfig.maxVideos);
513
+ const tailItems = allItems.slice(-this.prefetchConfig.maxItems);
452
514
  const cacheData = {
453
- items: tailVideos,
515
+ items: tailItems,
454
516
  savedAt: Date.now(),
455
517
  cursor: state.cursor
456
518
  };
@@ -493,6 +555,35 @@ var _FeedManager = class _FeedManager {
493
555
  // Trigger revalidation
494
556
  });
495
557
  }
558
+ /**
559
+ * Load prefetch cache synchronously (zero-flash optimization)
560
+ *
561
+ * Uses `storage.getSync()` for synchronous localStorage access.
562
+ * This allows hydrating the feed during React's synchronous render phase,
563
+ * preventing any flash of loading/empty state when cached data exists.
564
+ *
565
+ * Returns null if:
566
+ * - Prefetch cache is disabled
567
+ * - No storage adapter
568
+ * - Storage doesn't support sync reads (e.g., IndexedDB)
569
+ * - No cached data
570
+ *
571
+ * @returns Cached feed data or null
572
+ */
573
+ loadPrefetchCacheSync() {
574
+ if (!this.prefetchConfig.enabled) return null;
575
+ if (!this.storage) return null;
576
+ if (!this.storage.getSync) return null;
577
+ try {
578
+ const data = this.storage.getSync(this.prefetchConfig.storageKey);
579
+ if (!data || !data.items || data.items.length === 0) {
580
+ return null;
581
+ }
582
+ return data;
583
+ } catch {
584
+ return null;
585
+ }
586
+ }
496
587
  /**
497
588
  * Clear prefetch cache
498
589
  * Call when user logs out or data should be invalidated
@@ -505,26 +596,26 @@ var _FeedManager = class _FeedManager {
505
596
  }
506
597
  }
507
598
  /**
508
- * Evict videos that user has scrolled past from prefetch cache
599
+ * Evict items that user has scrolled past from prefetch cache
509
600
  * Called when user's focusedIndex changes
510
601
  *
511
- * Strategy: Remove all videos at or before current position
512
- * This ensures user doesn't rewatch videos on reload
602
+ * Strategy: Remove all items at or before current position
603
+ * This ensures user doesn't rewatch items on reload
513
604
  *
514
- * @param currentIndex - Current focused video index in feed
605
+ * @param currentIndex - Current focused item index in feed
515
606
  */
516
- async evictViewedVideosFromCache(currentIndex) {
607
+ async evictViewedItemsFromCache(currentIndex) {
517
608
  if (!this.prefetchConfig.enabled) return;
518
609
  if (!this.prefetchConfig.enableDynamicEviction) return;
519
610
  if (!this.storage) return;
520
611
  try {
521
612
  const cache = await this.loadPrefetchCache();
522
613
  if (!cache || cache.items.length === 0) return;
523
- const allVideos = this.getVideos();
524
- const currentVideo = allVideos[currentIndex];
525
- if (!currentVideo) return;
526
- const viewedVideoIds = new Set(allVideos.slice(0, currentIndex + 1).map((v) => v.id));
527
- const updatedItems = cache.items.filter((item) => !viewedVideoIds.has(item.id));
614
+ const allItems = this.getItems();
615
+ const currentItem = allItems[currentIndex];
616
+ if (!currentItem) return;
617
+ const viewedItemIds = new Set(allItems.slice(0, currentIndex + 1).map((v) => v.id));
618
+ const updatedItems = cache.items.filter((item) => !viewedItemIds.has(item.id));
528
619
  if (updatedItems.length === cache.items.length) return;
529
620
  if (updatedItems.length === 0) {
530
621
  await this.storage.remove(this.prefetchConfig.storageKey);
@@ -560,7 +651,7 @@ var _FeedManager = class _FeedManager {
560
651
  if (replace) {
561
652
  this.replaceItems(response.items);
562
653
  } else {
563
- this.addVideos(response.items);
654
+ this.addItems(response.items);
564
655
  }
565
656
  this.store.setState({
566
657
  cursor: response.nextCursor,
@@ -584,23 +675,23 @@ var _FeedManager = class _FeedManager {
584
675
  throw lastError;
585
676
  }
586
677
  /**
587
- * Add videos with deduplication
678
+ * Add items with deduplication
588
679
  * Triggers garbage collection if cache exceeds maxCacheSize
589
680
  */
590
- addVideos(videos) {
681
+ addItems(items) {
591
682
  const state = this.store.getState();
592
683
  const newItemsById = new Map(state.itemsById);
593
684
  const newDisplayOrder = [...state.displayOrder];
594
685
  const now = Date.now();
595
- for (const video of videos) {
596
- const existing = newItemsById.get(video.id);
686
+ for (const item of items) {
687
+ const existing = newItemsById.get(item.id);
597
688
  if (existing) {
598
- newItemsById.set(video.id, { ...existing, ...video });
689
+ newItemsById.set(item.id, { ...existing, ...item });
599
690
  } else {
600
- newItemsById.set(video.id, video);
601
- newDisplayOrder.push(video.id);
691
+ newItemsById.set(item.id, item);
692
+ newDisplayOrder.push(item.id);
602
693
  }
603
- this.accessOrder.set(video.id, now);
694
+ this.accessOrder.set(item.id, now);
604
695
  }
605
696
  this.store.setState({
606
697
  itemsById: newItemsById,
@@ -611,19 +702,19 @@ var _FeedManager = class _FeedManager {
611
702
  }
612
703
  }
613
704
  /**
614
- * Merge videos (for SWR revalidation)
615
- * Updates existing videos, adds new ones at the beginning
705
+ * Merge items (for SWR revalidation)
706
+ * Updates existing items, adds new ones at the beginning
616
707
  */
617
- mergeVideos(videos, prepend) {
708
+ mergeItems(items, prepend) {
618
709
  const state = this.store.getState();
619
710
  const newItemsById = new Map(state.itemsById);
620
711
  const newIds = [];
621
- for (const video of videos) {
622
- if (newItemsById.has(video.id)) {
623
- newItemsById.set(video.id, video);
712
+ for (const item of items) {
713
+ if (newItemsById.has(item.id)) {
714
+ newItemsById.set(item.id, item);
624
715
  } else {
625
- newItemsById.set(video.id, video);
626
- newIds.push(video.id);
716
+ newItemsById.set(item.id, item);
717
+ newIds.push(item.id);
627
718
  }
628
719
  }
629
720
  const newDisplayOrder = prepend ? [...newIds, ...state.displayOrder] : [...state.displayOrder, ...newIds];
@@ -696,9 +787,9 @@ var _FeedManager = class _FeedManager {
696
787
  * Run garbage collection using LRU (Least Recently Used) policy
697
788
  *
698
789
  * When cache size exceeds maxCacheSize:
699
- * 1. Sort videos by last access time (oldest first)
700
- * 2. Evict oldest videos until cache is within limit
701
- * 3. Keep videos that are currently in viewport (most recent in displayOrder)
790
+ * 1. Sort items by last access time (oldest first)
791
+ * 2. Evict oldest items until cache is within limit
792
+ * 3. Keep items that are currently in viewport (most recent in displayOrder)
702
793
  *
703
794
  * @returns Number of evicted items
704
795
  */
@@ -2577,9 +2668,9 @@ var OptimisticManager = class {
2577
2668
  this.eventListeners = /* @__PURE__ */ new Set();
2578
2669
  /** Retry timer */
2579
2670
  this.retryTimer = null;
2580
- /** Debounce timers for like/unlike per video */
2671
+ /** Debounce timers for like/unlike per item */
2581
2672
  this.likeDebounceTimers = /* @__PURE__ */ new Map();
2582
- /** Intended like state while debouncing (videoId -> isLiked) */
2673
+ /** Intended like state while debouncing (itemId -> isLiked) */
2583
2674
  this.intendedLikeState = /* @__PURE__ */ new Map();
2584
2675
  /** Debounce delay in ms */
2585
2676
  this.debounceDelay = 300;
@@ -2594,73 +2685,75 @@ var OptimisticManager = class {
2594
2685
  // PUBLIC API - ACTIONS
2595
2686
  // ═══════════════════════════════════════════════════════════════
2596
2687
  /**
2597
- * Like a video with optimistic update
2688
+ * Like an item with optimistic update
2598
2689
  */
2599
- async like(videoId) {
2600
- return this.performAction("like", videoId, async () => {
2690
+ async like(itemId) {
2691
+ return this.performAction("like", itemId, async () => {
2601
2692
  if (!this.interaction) throw new Error("No interaction adapter");
2602
- await this.interaction.like(videoId);
2693
+ await this.interaction.like(itemId);
2603
2694
  });
2604
2695
  }
2605
2696
  /**
2606
- * Unlike a video with optimistic update
2697
+ * Unlike an item with optimistic update
2607
2698
  */
2608
- async unlike(videoId) {
2609
- return this.performAction("unlike", videoId, async () => {
2699
+ async unlike(itemId) {
2700
+ return this.performAction("unlike", itemId, async () => {
2610
2701
  if (!this.interaction) throw new Error("No interaction adapter");
2611
- await this.interaction.unlike(videoId);
2702
+ await this.interaction.unlike(itemId);
2612
2703
  });
2613
2704
  }
2614
2705
  /**
2615
2706
  * Toggle like state with DEBOUNCE
2616
2707
  */
2617
- toggleLike(videoId) {
2618
- const video = this.feedManager?.getVideo(videoId);
2619
- if (!video) {
2620
- this.logger?.warn(`[OptimisticManager] Video not found for toggleLike: ${videoId}`);
2708
+ toggleLike(itemId) {
2709
+ const item = this.feedManager?.getItem(itemId);
2710
+ if (!item) {
2711
+ this.logger?.warn(`[OptimisticManager] Item not found for toggleLike: ${itemId}`);
2621
2712
  return;
2622
2713
  }
2623
- const currentIntended = this.intendedLikeState.get(videoId) ?? video.isLiked;
2714
+ const currentIntended = this.intendedLikeState.get(itemId) ?? item.isLiked;
2624
2715
  const newIsLiked = !currentIntended;
2625
- this.intendedLikeState.set(videoId, newIsLiked);
2716
+ this.intendedLikeState.set(itemId, newIsLiked);
2626
2717
  const likeDelta = newIsLiked ? 1 : -1;
2627
- const currentLikesFromStore = video.stats.likes;
2628
- this.feedManager?.updateVideo(videoId, {
2718
+ const currentLikesFromStore = item.stats.likes;
2719
+ this.feedManager?.updateItem(itemId, {
2629
2720
  isLiked: newIsLiked,
2630
- stats: { ...video.stats, likes: Math.max(0, currentLikesFromStore + likeDelta) }
2721
+ stats: { ...item.stats, likes: Math.max(0, currentLikesFromStore + likeDelta) }
2631
2722
  });
2632
- const actionId = `like-debounce-${videoId}`;
2723
+ const actionId = `like-debounce-${itemId}`;
2633
2724
  this.store.setState((state) => {
2634
2725
  const pendingActions = new Map(state.pendingActions);
2635
2726
  pendingActions.set(actionId, {
2636
2727
  id: actionId,
2637
- videoId,
2728
+ videoId: itemId,
2729
+ // Keep property name 'videoId' in PendingAction for compatibility if needed, but using itemId value
2638
2730
  type: newIsLiked ? "like" : "unlike",
2639
2731
  status: "pending",
2640
2732
  timestamp: Date.now(),
2641
2733
  retryCount: 0,
2642
2734
  rollbackData: {
2643
2735
  isLiked: currentIntended,
2644
- stats: { ...video.stats }
2736
+ stats: { ...item.stats }
2645
2737
  }
2738
+ // Cast back to VideoItem if needed for contract
2646
2739
  });
2647
2740
  return { pendingActions, hasPending: true };
2648
2741
  });
2649
- const existingTimer = this.likeDebounceTimers.get(videoId);
2742
+ const existingTimer = this.likeDebounceTimers.get(itemId);
2650
2743
  if (existingTimer) {
2651
2744
  clearTimeout(existingTimer);
2652
2745
  }
2653
2746
  const timer = setTimeout(() => {
2654
- this.likeDebounceTimers.delete(videoId);
2655
- this.executeDebouncedLikeApi(videoId, actionId);
2747
+ this.likeDebounceTimers.delete(itemId);
2748
+ this.executeDebouncedLikeApi(itemId, actionId);
2656
2749
  }, this.debounceDelay);
2657
- this.likeDebounceTimers.set(videoId, timer);
2750
+ this.likeDebounceTimers.set(itemId, timer);
2658
2751
  }
2659
2752
  /**
2660
2753
  * Execute API call after debounce delay
2661
2754
  */
2662
- async executeDebouncedLikeApi(videoId, actionId) {
2663
- const finalIntended = this.intendedLikeState.get(videoId);
2755
+ async executeDebouncedLikeApi(itemId, actionId) {
2756
+ const finalIntended = this.intendedLikeState.get(itemId);
2664
2757
  if (finalIntended === void 0) {
2665
2758
  this.removePendingAction(actionId);
2666
2759
  return;
@@ -2668,15 +2761,15 @@ var OptimisticManager = class {
2668
2761
  try {
2669
2762
  if (!this.interaction) throw new Error("No interaction adapter");
2670
2763
  if (finalIntended) {
2671
- await this.interaction.like(videoId);
2764
+ await this.interaction.like(itemId);
2672
2765
  } else {
2673
- await this.interaction.unlike(videoId);
2766
+ await this.interaction.unlike(itemId);
2674
2767
  }
2675
- if (this.intendedLikeState.get(videoId) === finalIntended) {
2676
- this.intendedLikeState.delete(videoId);
2768
+ if (this.intendedLikeState.get(itemId) === finalIntended) {
2769
+ this.intendedLikeState.delete(itemId);
2677
2770
  }
2678
2771
  } catch (error) {
2679
- this.handleDebouncedApiError(videoId, finalIntended, error);
2772
+ this.handleDebouncedApiError(itemId, finalIntended, error);
2680
2773
  } finally {
2681
2774
  this.removePendingAction(actionId);
2682
2775
  }
@@ -2684,19 +2777,19 @@ var OptimisticManager = class {
2684
2777
  /**
2685
2778
  * Handle errors from debounced API calls
2686
2779
  */
2687
- handleDebouncedApiError(videoId, finalIntended, error) {
2780
+ handleDebouncedApiError(itemId, finalIntended, error) {
2688
2781
  const err = error instanceof Error ? error : new Error(String(error));
2689
- this.logger?.error(`[OptimisticManager] API failed for ${videoId}`, err);
2690
- if (!this.intendedLikeState.has(videoId)) {
2691
- const currentVideo = this.feedManager?.getVideo(videoId);
2692
- if (currentVideo) {
2782
+ this.logger?.error(`[OptimisticManager] API failed for ${itemId}`, err);
2783
+ if (!this.intendedLikeState.has(itemId)) {
2784
+ const currentItem = this.feedManager?.getItem(itemId);
2785
+ if (currentItem) {
2693
2786
  const rollbackIsLiked = !finalIntended;
2694
2787
  const rollbackDelta = rollbackIsLiked ? 1 : -1;
2695
- this.feedManager?.updateVideo(videoId, {
2788
+ this.feedManager?.updateItem(itemId, {
2696
2789
  isLiked: rollbackIsLiked,
2697
2790
  stats: {
2698
- ...currentVideo.stats,
2699
- likes: Math.max(0, currentVideo.stats.likes + rollbackDelta)
2791
+ ...currentItem.stats,
2792
+ likes: Math.max(0, currentItem.stats.likes + rollbackDelta)
2700
2793
  }
2701
2794
  });
2702
2795
  }
@@ -2718,21 +2811,21 @@ var OptimisticManager = class {
2718
2811
  /**
2719
2812
  * Toggle follow state
2720
2813
  */
2721
- async toggleFollow(videoId) {
2722
- const video = this.feedManager?.getVideo(videoId);
2723
- if (!video) return false;
2724
- return video.isFollowing ? this.unfollow(videoId) : this.follow(videoId);
2814
+ async toggleFollow(itemId) {
2815
+ const item = this.feedManager?.getItem(itemId);
2816
+ if (!item) return false;
2817
+ return item.isFollowing ? this.unfollow(itemId) : this.follow(itemId);
2725
2818
  }
2726
- async follow(videoId) {
2727
- return this.performAction("follow", videoId, async () => {
2819
+ async follow(itemId) {
2820
+ return this.performAction("follow", itemId, async () => {
2728
2821
  if (!this.interaction) throw new Error("No interaction adapter");
2729
- await this.interaction.follow(videoId);
2822
+ await this.interaction.follow(itemId);
2730
2823
  });
2731
2824
  }
2732
- async unfollow(videoId) {
2733
- return this.performAction("unfollow", videoId, async () => {
2825
+ async unfollow(itemId) {
2826
+ return this.performAction("unfollow", itemId, async () => {
2734
2827
  if (!this.interaction) throw new Error("No interaction adapter");
2735
- await this.interaction.unfollow(videoId);
2828
+ await this.interaction.unfollow(itemId);
2736
2829
  });
2737
2830
  }
2738
2831
  addEventListener(listener) {
@@ -2754,10 +2847,10 @@ var OptimisticManager = class {
2754
2847
  getPendingActions() {
2755
2848
  return [...this.store.getState().pendingActions.values()];
2756
2849
  }
2757
- hasPendingAction(videoId, type) {
2850
+ hasPendingAction(itemId, type) {
2758
2851
  const actions = this.store.getState().pendingActions;
2759
2852
  for (const action of actions.values()) {
2760
- if (action.videoId === videoId && action.status === "pending" && (!type || action.type === type)) {
2853
+ if (action.videoId === itemId && action.status === "pending" && (!type || action.type === type)) {
2761
2854
  return true;
2762
2855
  }
2763
2856
  }
@@ -2816,23 +2909,23 @@ var OptimisticManager = class {
2816
2909
  }
2817
2910
  }
2818
2911
  }
2819
- async performAction(type, videoId, apiCall) {
2820
- const video = this.feedManager?.getVideo(videoId);
2821
- if (!video) return false;
2822
- if (this.hasPendingAction(videoId, type)) {
2912
+ async performAction(type, itemId, apiCall) {
2913
+ const item = this.feedManager?.getItem(itemId);
2914
+ if (!item) return false;
2915
+ if (this.hasPendingAction(itemId, type)) {
2823
2916
  return false;
2824
2917
  }
2825
2918
  const action = {
2826
2919
  id: generateActionId(),
2827
2920
  type,
2828
- videoId,
2829
- rollbackData: this.createRollbackData(type, video),
2921
+ videoId: itemId,
2922
+ rollbackData: this.createRollbackData(type, item),
2830
2923
  timestamp: Date.now(),
2831
2924
  status: "pending",
2832
2925
  retryCount: 0
2833
2926
  };
2834
2927
  this.addPendingAction(action);
2835
- this.applyOptimisticUpdate(type, video);
2928
+ this.applyOptimisticUpdate(type, item);
2836
2929
  this.emit({ type: "actionStart", action });
2837
2930
  try {
2838
2931
  await apiCall();
@@ -2846,32 +2939,32 @@ var OptimisticManager = class {
2846
2939
  return false;
2847
2940
  }
2848
2941
  }
2849
- createRollbackData(type, video) {
2942
+ createRollbackData(type, item) {
2850
2943
  if (type === "like" || type === "unlike") {
2851
- return { isLiked: video.isLiked, stats: { ...video.stats } };
2944
+ return { isLiked: item.isLiked, stats: { ...item.stats } };
2852
2945
  }
2853
- return { isFollowing: video.isFollowing };
2946
+ return { isFollowing: item.isFollowing };
2854
2947
  }
2855
- applyOptimisticUpdate(type, video) {
2948
+ applyOptimisticUpdate(type, item) {
2856
2949
  if (!this.feedManager) return;
2857
2950
  if (type === "like") {
2858
- this.feedManager.updateVideo(video.id, {
2951
+ this.feedManager.updateItem(item.id, {
2859
2952
  isLiked: true,
2860
- stats: { ...video.stats, likes: video.stats.likes + 1 }
2953
+ stats: { ...item.stats, likes: item.stats.likes + 1 }
2861
2954
  });
2862
2955
  } else if (type === "unlike") {
2863
- this.feedManager.updateVideo(video.id, {
2956
+ this.feedManager.updateItem(item.id, {
2864
2957
  isLiked: false,
2865
- stats: { ...video.stats, likes: Math.max(0, video.stats.likes - 1) }
2958
+ stats: { ...item.stats, likes: Math.max(0, item.stats.likes - 1) }
2866
2959
  });
2867
2960
  } else if (type === "follow") {
2868
- this.feedManager.updateVideo(video.id, { isFollowing: true });
2961
+ this.feedManager.updateItem(item.id, { isFollowing: true });
2869
2962
  } else if (type === "unfollow") {
2870
- this.feedManager.updateVideo(video.id, { isFollowing: false });
2963
+ this.feedManager.updateItem(item.id, { isFollowing: false });
2871
2964
  }
2872
2965
  }
2873
2966
  applyRollback(action) {
2874
- this.feedManager?.updateVideo(action.videoId, action.rollbackData);
2967
+ this.feedManager?.updateItem(action.videoId, action.rollbackData);
2875
2968
  this.emit({ type: "actionRollback", action });
2876
2969
  }
2877
2970
  addPendingAction(action) {
@@ -2929,9 +3022,9 @@ var OptimisticManager = class {
2929
3022
  await this.interaction?.follow(updatedAction.videoId);
2930
3023
  else if (updatedAction.type === "unfollow")
2931
3024
  await this.interaction?.unfollow(updatedAction.videoId);
2932
- const currentVideo = this.feedManager?.getVideo(updatedAction.videoId);
2933
- if (currentVideo) {
2934
- this.applyOptimisticUpdate(updatedAction.type, currentVideo);
3025
+ const currentItem = this.feedManager?.getItem(updatedAction.videoId);
3026
+ if (currentItem) {
3027
+ this.applyOptimisticUpdate(updatedAction.type, currentItem);
2935
3028
  }
2936
3029
  this.markActionSuccess(updatedAction.id);
2937
3030
  } catch (e) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@xhub-short/core",
3
3
  "sideEffects": false,
4
- "version": "0.1.0-beta.14",
4
+ "version": "0.1.0-beta.17",
5
5
  "type": "module",
6
6
  "publishConfig": {
7
7
  "access": "public"
@@ -21,7 +21,7 @@
21
21
  ],
22
22
  "dependencies": {
23
23
  "zustand": "^5.0.0",
24
- "@xhub-short/contracts": "0.1.0-beta.14"
24
+ "@xhub-short/contracts": "0.1.0-beta.17"
25
25
  },
26
26
  "devDependencies": {
27
27
  "tsup": "^8.3.0",