|
5 | 5 | */ |
6 | 6 |
|
7 | 7 | import { state, ROWS_PER_PAGE } from './state.js'; |
8 | | -import { rtgColor, rtgIcon, escapeHTML, safeString, getDOMElement } from './helpers.js'; |
| 8 | +import { rtgIcon, escapeHTML, safeString, getDOMElement } from './helpers.js'; |
9 | 9 | import { VALIDATION_PATTERNS, NUMERIC_BOUNDS, validateNumeric, safeImageURL, safeLinkURL, safeReviewLinkURL } from './validation.js'; |
10 | 10 | import { TOOLTIP_DATA, createInfoTooltip } from './tooltips.js'; |
11 | 11 | import { createRatingHTML } from './ratings.js'; |
@@ -48,6 +48,23 @@ export function observeCardImages(container) { |
48 | 48 | images.forEach(img => imageObserver.observe(img)); |
49 | 49 | } |
50 | 50 |
|
| 51 | +/** |
| 52 | + * Disconnect the shared IntersectionObserver. Call when the guide is |
| 53 | + * being torn down or the card container is about to be cleared in bulk. |
| 54 | + */ |
| 55 | +export function disconnectImageObserver() { |
| 56 | + if (imageObserver) { |
| 57 | + imageObserver.disconnect(); |
| 58 | + imageObserver = null; |
| 59 | + } |
| 60 | +} |
| 61 | + |
| 62 | +// Clean up when the page is unloaded so we don't hold the observer across |
| 63 | +// page transitions in persistent-navigation setups. |
| 64 | +if (typeof window !== 'undefined') { |
| 65 | + window.addEventListener('pagehide', disconnectImageObserver, { once: true }); |
| 66 | +} |
| 67 | + |
51 | 68 | function removeSkeletonLoader() { |
52 | 69 | const skeleton = document.getElementById('rtg-skeleton-loader'); |
53 | 70 | if (skeleton) skeleton.remove(); |
@@ -364,31 +381,6 @@ export function createSingleCard(row) { |
364 | 381 | infoButton.dataset.tooltipKey = 'Efficiency Score'; |
365 | 382 | infoButton.setAttribute('aria-label', 'More info about Efficiency Score'); |
366 | 383 | infoButton.setAttribute('type', 'button'); |
367 | | - infoButton.style.cssText = ` |
368 | | - background: none; |
369 | | - border: none; |
370 | | - color: var(--rtg-text-muted); |
371 | | - font-size: 14px; |
372 | | - cursor: pointer; |
373 | | - padding: 2px; |
374 | | - border-radius: 50%; |
375 | | - width: 20px; |
376 | | - height: 20px; |
377 | | - display: flex; |
378 | | - align-items: center; |
379 | | - justify-content: center; |
380 | | - transition: all 0.2s ease; |
381 | | - `; |
382 | | - |
383 | | - infoButton.addEventListener('mouseenter', () => { |
384 | | - infoButton.style.color = rtgColor('accent'); |
385 | | - infoButton.style.backgroundColor = `color-mix(in srgb, ${rtgColor('accent')} 10%, transparent)`; |
386 | | - }); |
387 | | - |
388 | | - infoButton.addEventListener('mouseleave', () => { |
389 | | - infoButton.style.color = rtgColor('text-muted'); |
390 | | - infoButton.style.backgroundColor = 'transparent'; |
391 | | - }); |
392 | 384 |
|
393 | 385 | scoreSection.appendChild(scoreText); |
394 | 386 | scoreSection.appendChild(infoButton); |
@@ -432,31 +424,6 @@ export function createSingleCard(row) { |
432 | 424 | } |
433 | 425 | roamerInfoBtn.setAttribute('aria-label', 'More info about Real-World Efficiency'); |
434 | 426 | roamerInfoBtn.setAttribute('type', 'button'); |
435 | | - roamerInfoBtn.style.cssText = ` |
436 | | - background: none; |
437 | | - border: none; |
438 | | - color: var(--rtg-text-muted); |
439 | | - font-size: 14px; |
440 | | - cursor: pointer; |
441 | | - padding: 2px; |
442 | | - border-radius: 50%; |
443 | | - width: 20px; |
444 | | - height: 20px; |
445 | | - display: flex; |
446 | | - align-items: center; |
447 | | - justify-content: center; |
448 | | - transition: all 0.2s ease; |
449 | | - `; |
450 | | - |
451 | | - roamerInfoBtn.addEventListener('mouseenter', () => { |
452 | | - roamerInfoBtn.style.color = rtgColor('accent'); |
453 | | - roamerInfoBtn.style.backgroundColor = `color-mix(in srgb, ${rtgColor('accent')} 10%, transparent)`; |
454 | | - }); |
455 | | - |
456 | | - roamerInfoBtn.addEventListener('mouseleave', () => { |
457 | | - roamerInfoBtn.style.color = rtgColor('text-muted'); |
458 | | - roamerInfoBtn.style.backgroundColor = 'transparent'; |
459 | | - }); |
460 | 427 |
|
461 | 428 | roamerScore.appendChild(roamerText); |
462 | 429 | roamerScore.appendChild(roamerInfoBtn); |
@@ -527,8 +494,7 @@ export function createSingleCard(row) { |
527 | 494 | tagLabel.textContent = 'Tags'; |
528 | 495 |
|
529 | 496 | const tagValue = document.createElement('span'); |
530 | | - tagValue.className = 'tire-card-spec-value'; |
531 | | - tagValue.style.cssText = 'display: flex; flex-wrap: wrap; gap: 4px; justify-content: flex-end;'; |
| 497 | + tagValue.className = 'tire-card-spec-value tire-card-tag-list'; |
532 | 498 |
|
533 | 499 | tagList.forEach(tag => { |
534 | 500 | const tagEl = document.createElement('span'); |
@@ -593,10 +559,14 @@ export function createSingleCard(row) { |
593 | 559 |
|
594 | 560 | card.appendChild(actionsContainer); |
595 | 561 |
|
596 | | - if (state.cardCache.size >= 100) { |
597 | | - // Evict oldest 20 entries to avoid thrashing on every new card. |
598 | | - const keysToDelete = [...state.cardCache.keys()].slice(0, 20); |
599 | | - keysToDelete.forEach(k => state.cardCache.delete(k)); |
| 562 | + // Tight LRU ceiling. Map iteration order is insertion order, so evicting |
| 563 | + // the first key removes the oldest entry. Kept small (20) because cloned |
| 564 | + // card nodes retain their subtree and we don't want them holding images |
| 565 | + // off the GC indefinitely. |
| 566 | + const CARD_CACHE_MAX = 20; |
| 567 | + if (state.cardCache.size >= CARD_CACHE_MAX) { |
| 568 | + const firstKey = state.cardCache.keys().next().value; |
| 569 | + if (firstKey !== undefined) state.cardCache.delete(firstKey); |
600 | 570 | } |
601 | 571 | state.cardCache.set(cacheKey, card.cloneNode(true)); |
602 | 572 |
|
|
0 commit comments