PR 10977. Performance hotfix into release-0.0.25. English first paint drops Translate, a second Roboto sheet, CDN Bootstrap, Cropme, and a duplicate IVS player. The profile card paints before posts, merch, and welcome video.
What moved
| Thing | Before | After |
|---|---|---|
| Google Translate | element.js on every load | LocaleService.ensureGoogleTranslateLoaded() |
| Roboto | preload 400/700 plus the full sheet | one print/onload stylesheet |
| CDN scripts | Bootstrap, Cropme, IVS | gone; IVS is npm, Veriff stays idle |
| Profile markup | cs.isBrowser && isBasicProfileLoaded | isBasicProfileLoaded, including SSR |
| Posts, merch, welcome video | same turn as the card | scheduleAfterFirstPaint() |
| Sidebar, welcome video JS | eager imports | @defer (on viewport; prefetch on idle) |
| User GET cache | REFRESH_EVERY_N everywhere | PRERENDERED on server |
| Bootstrap CSS | bootstrap/scss/bootstrap | curated _bootstrap-partials.scss |
| S3 Cache-Control | HTML immutable for a year | HTML must-revalidate |
Where the effort went
afterNextRender.
index.html and prerendered HTML must revalidate.
21 files, +452 / −344. Head cb09e9d27b. Diffs are grouped by concern, not GitHub file-alpha order.
Order of work
- Cut Translate, CDN scripts
- One Roboto sheet
- SSR the card
@defersidebar and welcome video
- Curated partials
- Icomoon
font-display: swap
- HTML must-revalidate
- Critical CSS on WLS prod/qa
Each page ahead is one concern: what changed, then the hunks. A hunk that does two jobs is shown in both pages.
The document shell was downloading scripts and styles the first screen does not need. This is the change a visitor feels before Angular boots.
What left index.html
- English visitors no longer pay for Google Translate on every load. The script itself moves to the next page.
- One Roboto stylesheet, loaded as print then swapped to all. The extra 400/700 preload is gone.
- CDN Bootstrap, Cropme, and the Amazon IVS player script are removed. IVS already ships as npm
amazon-ivs-player. - Veriff still idle-loads, and only on creator-signup routes.
- Critical CSS now uses
--configBgColor/--configBgTextColorso the splash matches the tenant. - Also deleted: the large Organization JSON-LD block, iPhone 17 splash links,
apple-mobile-web-app-capable, a duplicate 192 favicon, and the IVS dns-prefetch. Treat the JSON-LD and splash removals as review items.
Still on the first-paint path
- One Roboto sheet, print then all
- GTM dns-prefetch
- Veriff, idle, signup routes only
- The googtrans cookie, if locale is not
en
Splash, icons, JSON-LD, splash colors
src/index.html@@ -42,60 +42,49 @@ <!-- Favicon and App Icons --> <link rel="apple-touch-icon" sizes="120x120" href="./assets/splashscreen/apple-icon-120.png"> <link rel="apple-touch-icon" sizes="152x152" href="./assets/splashscreen/apple-icon-152.png"> <link rel="apple-touch-icon" sizes="167x167" href="./assets/splashscreen/apple-icon-167.png"> <link rel="apple-touch-icon" sizes="180x180" href="./assets/splashscreen/apple-icon-180.png"> <link rel="icon" type="image/x-icon" href="./assets/splashscreen/apple-icon-180.png"/> <link rel="icon" sizes="192x192" href="./assets/splashscreen/apple-icon-192.png">- <link rel="icon" sizes="192x192" href="./assets/splashscreen/apple-icon-192.png"> <link id="app-manifest" href="./assets/Json/manifest.json" rel="manifest"/>- <!-- DNS prefetch for Amazon IVS player to speed up connection -->- <link rel="dns-prefetch" href="//player.live-video.net">- <meta name="apple-mobile-web-app-capable" content="yes"> <!-- Splash Image (for iOS) --> <!-- iPad Pro --> <link rel="apple-touch-startup-image" href="./assets/splashscreen/apple-splash-2048-2732.png" media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"> <link rel="apple-touch-startup-image" href="./assets/splashscreen/apple-splash-2732-2048.png" media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"> <link rel="apple-touch-startup-image" href="./assets/splashscreen/apple-splash-1668-2388.png" media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"> <link rel="apple-touch-startup-image" href="./assets/splashscreen/apple-splash-2388-1668.png" media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"> <link rel="apple-touch-startup-image" href="./assets/splashscreen/apple-splash-1536-2048.png" media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"> <link rel="apple-touch-startup-image" href="./assets/splashscreen/apple-splash-2048-1536.png" media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"> <link rel="apple-touch-startup-image" href="./assets/splashscreen/apple-splash-1640-2360.png" media="(device-width: 820px) and (device-height: 1180px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"> <link rel="apple-touch-startup-image" href="./assets/splashscreen/apple-splash-2360-1640.png" media="(device-width: 820px) and (device-height: 1180px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"> <link rel="apple-touch-startup-image" href="./assets/splashscreen/apple-splash-1668-2224.png" media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"> <link rel="apple-touch-startup-image" href="./assets/splashscreen/apple-splash-2224-1668.png" media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"> <link rel="apple-touch-startup-image" href="./assets/splashscreen/apple-splash-1620-2160.png" media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)">- <link rel="apple-touch-startup-image" href="./assets/splashscreen/apple-splash-2160-1620.png" media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"> <link rel="apple-touch-startup-image" href="./assets/splashscreen/apple-splash-1488-2266.png" media="(device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)">+ <link rel="apple-touch-startup-image" href="./assets/splashscreen/apple-splash-2160-1620.png" media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)">+ <link rel="apple-touch-startup-image" href="./assets/splashscreen/apple-splash-1488-2266.png" media="(device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"> <link rel="apple-touch-startup-image" href="./assets/splashscreen/apple-splash-2266-1488.png" media="(device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"> <!-- iPhone 16 Series - 2024 --> <!-- iPhone 16 Pro Max (6.9") --> <link rel="apple-touch-startup-image" href="./assets/splashscreen/apple-splash-1320-2868.png" media="(device-width: 440px) and (device-height: 956px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"> <link rel="apple-touch-startup-image" href="./assets/splashscreen/apple-splash-2868-1320.png" media="(device-width: 440px) and (device-height: 956px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"> <!-- iPhone 16 Pro (6.3") --> <link rel="apple-touch-startup-image" href="./assets/splashscreen/apple-splash-1206-2622.png" media="(device-width: 402px) and (device-height: 874px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"> <link rel="apple-touch-startup-image" href="./assets/splashscreen/apple-splash-2622-1206.png" media="(device-width: 402px) and (device-height: 874px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"> <!-- iPhone 16 Plus (6.7") --> <link rel="apple-touch-startup-image" href="./assets/splashscreen/apple-splash-1290-2796.png" media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"> <link rel="apple-touch-startup-image" href="./assets/splashscreen/apple-splash-2796-1290.png" media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"> <!-- iPhone 16 (6.1") --> <link rel="apple-touch-startup-image" href="./assets/splashscreen/apple-splash-1179-2556.png" media="(device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"> <link rel="apple-touch-startup-image" href="./assets/splashscreen/apple-splash-2556-1179.png" media="(device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)">- <!-- iPhone 17 Series - 2025 -->- <!-- iPhone 17 Pro Max (6.9") -->- <link rel="apple-touch-startup-image" href="./assets/splashscreen/apple-splash-1320-2868.png" media="(device-width: 440px) and (device-height: 956px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)">- <link rel="apple-touch-startup-image" href="./assets/splashscreen/apple-splash-2868-1320.png" media="(device-width: 440px) and (device-height: 956px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)">- <!-- iPhone 17 Pro (6.3") -->- <link rel="apple-touch-startup-image" href="./assets/splashscreen/apple-splash-1206-2622.png" media="(device-width: 402px) and (device-height: 874px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)">- <link rel="apple-touch-startup-image" href="./assets/splashscreen/apple-splash-2622-1206.png" media="(device-width: 402px) and (device-height: 874px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)">- <!-- iPhone 15 Series and earlier --> <link rel="apple-touch-startup-image" href="./assets/splashscreen/apple-splash-1170-2532.png" media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"> <link rel="apple-touch-startup-image" href="./assets/splashscreen/apple-splash-2532-1170.png" media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"> <link rel="apple-touch-startup-image" href="./assets/splashscreen/apple-splash-1284-2778.png" media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"> <link rel="apple-touch-startup-image" href="./assets/splashscreen/apple-splash-2778-1284.png" media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"> <link rel="apple-touch-startup-image" href="./assets/splashscreen/apple-splash-1125-2436.png" media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"> <link rel="apple-touch-startup-image" href="./assets/splashscreen/apple-splash-2436-1125.png" media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"> <link rel="apple-touch-startup-image" href="./assets/splashscreen/apple-splash-1242-2688.png" media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)">src/index.html@@ -135,119 +124,27 @@ <script type="application/ld+json"> { "@context": "https://schema.org", "@type": "WebSite", "name": "TopFan", "alternateName": "TopFan Platform", "url": "https://www.topfan.com" }- </script> -- <script type="application/ld+json">- {- "@context": "https://schema.org",- "@type": "Organization",- "name": "TopFan",- "alternateName": "TopFan",- "url": "https://www.topfan.com", - "logo": "https://cdn-www.topfans.com/TopFans/1768394518_topfan_logo.jpg",- "description": "The leading creator monetization and fan engagement platform that provides creators with their own personal branded website and app, enabling direct-to-fan content, community and commerce.",- "foundingDate": "2013",- "founder": {- "@type": "Organization",- "name": "TopFan Inc."- },- "address": {- "@type": "PostalAddress",- "addressLocality": "Denver",- "addressRegion": "Colorado",- "addressCountry": "US"- },- "contactPoint": {- "@type": "ContactPoint",- "contactType": "customer service",- "url": "https://www.topfan.com/help-faq"- },- "sameAs": [- "https://www.topfan.com"- ],- "keywords": "creator monetization, fan engagement, subscription platform, direct-to-fan, content creators, influencers, musicians, athletes, fitness, wellness",- "award": "Leading Creator Monetization Platform",- "slogan": "The Best Platform for Creators",- "hasOfferCatalog": {- "@type": "OfferCatalog",- "name": "Creator Services",- "itemListElement": [- {- "@type": "Offer",- "itemOffered": {- "@type": "Service",- "name": "Premium Content Subscriptions",- "description": "Tiered membership subscriptions for exclusive content"- }- },- {- "@type": "Offer", - "itemOffered": {- "@type": "Service",- "name": "Merchandise Sales", - "description": "Integrated e-commerce for creator merchandise"- }- },- {- "@type": "Offer",- "itemOffered": {- "@type": "Service",- "name": "Live Streaming",- "description": "Live streaming capabilities with monetization"- }- },- {- "@type": "Offer",- "itemOffered": {- "@type": "Service",- "name": "Direct Messaging",- "description": "Paid direct messaging between creators and fans"- }- },- {- "@type": "Offer",- "itemOffered": {- "@type": "Service",- "name": "Content Publishing",- "description": "Multi-format content publishing tools"- }- }- ]- },- "knowsAbout": [- "Creator Economy",- "Fan Engagement", - "Content Monetization",- "Direct-to-Fan Platforms",- "Subscription Services",- "Musicians",- "Athletes", - "Fitness Professionals",- "Entertainers",- "Content Creators"- ]- } </script> <!-- Critical CSS for above-the-fold content --> <style> /* Critical CSS for initial paint */ app-root { display: block; min-height: 80vh; } body { margin: 0; font-family: Roboto, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;- background-color: #013956;- color: #ffffff;+ background-color: var(--configBgColor, #013956);+ color: var(--configBgTextColor, #ffffff); } @keyframes loading { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } /* Hide non-critical elements during initial load */ .non-critical { opacity: 0; transition: opacity 0.3s ease; } .loaded .non-critical { opacity: 1; } /* Prevent CLS by reserving space for hero section */ .promotional-banner {
Fonts, IVS, Bootstrap, Cropme
src/index.html@@ -274,58 +171,38 @@ html.social-callback-boot body { transition: none !important; background-color: var(--configSecondaryBgColor, #000); } </style> <!-- Remove non-critical preconnects; keep as dns-prefetch below --> - <!-- Fonts and Styles with optimized loading -->- <!-- Preload critical font weights (400 and 700) to prevent CLS -->- <link rel="preload" as="style" href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap">+ <!-- Fonts: one Roboto stylesheet (print/onload). Do not add a second 400/700 preload. --> <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@100;300;400;500;700;900&display=swap" rel="stylesheet" media="print" onload="this.media='all'"/> <noscript><link href="https://fonts.googleapis.com/css2?family=Roboto:wght@100;300;400;500;700;900&display=swap" rel="stylesheet"></noscript> <!-- DNS prefetch for external domains --> <link rel="dns-prefetch" href="//www.googletagmanager.com"> <link rel="dns-prefetch" href="//cdn.veriff.me">- <link rel="dns-prefetch" href="//cdn.jsdelivr.net">- <!-- 3rd-party SDKs - Idle-until-urgent -->+ <!-- Veriff only — IVS is npm amazon-ivs-player; Bootstrap/Cropme are bundled or unused --> <script> function loadScript(src, attrs) { var s = document.createElement('script'); s.src = src; if (attrs) Object.keys(attrs).forEach(function(k){ s.setAttribute(k, attrs[k]); }); document.head.appendChild(s); } function onIdle(cb){ if ('requestIdleCallback' in window) requestIdleCallback(cb, {timeout: 3000}); else setTimeout(cb, 1500); }- // Lazy-load Amazon IVS player on first user interaction or navigation- var ivsLoaded = false;- function loadIVS() {- if (ivsLoaded) return;- ivsLoaded = true;- loadScript('https://player.live-video.net/1.45.0/amazon-ivs-player.min.js', {defer: ''});- }- // Load IVS on first user interaction (click/scroll/touch)- ['click','scroll','touchstart'].forEach(function(evt){- document.addEventListener(evt, function onInteraction(){- onIdle(loadIVS);- ['click','scroll','touchstart'].forEach(function(e){- document.removeEventListener(e, onInteraction);- });- }, {once: true, passive: true});- }); window.addEventListener('load', function() { onIdle(function(){- // Load Veriff only on specific routes var veriffLoaded = false; function shouldLoadVeriff(path){ return /^\/auth\/creators-signup(\/|$)?/.test(path) || /^\/wallet\/current-balance(\/|$)?/.test(path) || /^\/edit-preference\/become-a-creator\/creator-profile-info(\/|$)?/.test(path) || /^\/edit-preference\/become-a-creator\/confirm-creator(\/|$)?/.test(path) || /^\/edit-preference\/become-a-creator\/verify-identity(\/|$)?/.test(path); }src/index.html@@ -333,34 +210,24 @@ if (veriffLoaded) return; var p = (window.location && window.location.pathname) || ''; if (shouldLoadVeriff(p)) { loadScript('https://cdn.veriff.me/sdk/js/1.5/veriff.min.js'); loadScript('https://cdn.veriff.me/incontext/js/v1/veriff.js'); veriffLoaded = true; } }- // initial check maybeLoadVeriff();- // observe SPA navigations (function(){ var origPush = history.pushState; var origReplace = history.replaceState; history.pushState = function(){ origPush.apply(this, arguments); setTimeout(maybeLoadVeriff, 0); }; history.replaceState = function(){ origReplace.apply(this, arguments); setTimeout(maybeLoadVeriff, 0); }; window.addEventListener('popstate', function(){ setTimeout(maybeLoadVeriff, 0); }); })();- loadScript('https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js', {- integrity: 'sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM',- crossorigin: 'anonymous'- });- // Skip Cropme on home route- if (window.location && window.location.pathname !== '/') {- loadScript('https://cdn.jsdelivr.net/npm/cropme@latest/dist/cropme.min.js');- } }); }); </script></head> <body> <script> (function() {
Translate is a large third-party script. The PR keeps the off-screen host node and the googtrans cookie, but moves element.js behind LocaleService.
Who loads the script now
googtrans early so a restored locale can apply once the script arrives.
src/index.html@@ -401,37 +268,25 @@ <div id="google_translate_element"></div> <main id="main-content" role="main"> <app-root> </app-root> </main> <noscript>Please enable JavaScript to continue using this application.</noscript>- <!-- Set Google Translate cookie early so translation applies on load -->+ <!-- Set googtrans early; element.js loads on demand via LocaleService --> <script> (function() { var lang = ''; try { lang = localStorage.getItem('tf_locale') || ''; } catch(e) {} if (lang && lang !== 'en') { var val = '/en/' + lang; var parts = location.hostname.split('.'); var root = parts.length > 2 ? '.' + parts.slice(-2).join('.') : location.hostname; document.cookie = 'googtrans=' + val + ';path=/'; document.cookie = 'googtrans=' + val + ';path=/;domain=' + location.hostname; document.cookie = 'googtrans=' + val + ';path=/;domain=' + root; } })(); </script>-- <!-- Google Website Translator -->- <script>- function googleTranslateElementInit() {- new google.translate.TranslateElement({- pageLanguage: 'en',- autoDisplay: false,- layout: google.translate.TranslateElement.InlineLayout.SIMPLE- }, 'google_translate_element');- }- </script>- <script src="//translate.google.com/translate_a/element.js?cb=googleTranslateElementInit"></script></body>-</html>\ No newline at end of file+</html>src/app/components/language-switcher/language-switcher.component.ts@@ -51,16 +51,17 @@ export class LanguageSwitcherComponent implements OnInit, OnDestroy { return lang ? lang.name : LOCALE_CONSTANTS.SUPPORTED_LANGUAGES[0].name; } toggleDropdown(): void { this.isDropdownOpen = !this.isDropdownOpen; if (this.isDropdownOpen) { this.searchTerm = ''; this.filteredLanguages = [...this.languages];+ void this.localeService.ensureGoogleTranslateLoaded(); } } selectLanguage(langCode: string): void { this.localeService.setLocale(langCode); this.isDropdownOpen = false; }
LocaleService
src/app/core/i18n/locale.service.ts@@ -4,48 +4,127 @@ import { BehaviorSubject } from 'rxjs';import { LOCALE_CONSTANTS } from './locale.constants';declare const google: any;@Injectable({ providedIn: 'root' })export class LocaleService { private readonly isBrowser: boolean; private readonly localeSubject = new BehaviorSubject<string>(LOCALE_CONSTANTS.DEFAULT_LOCALE);+ private translateScriptLoading: Promise<void> | null = null; readonly locale$ = this.localeSubject.asObservable(); constructor(@Inject(PLATFORM_ID) platformId: Object) { this.isBrowser = isPlatformBrowser(platformId); if (this.isBrowser) { const savedLocale = this.getSavedLocale(); this.localeSubject.next(savedLocale); if (savedLocale !== LOCALE_CONSTANTS.DEFAULT_LOCALE) { this.applyOnLoad(savedLocale); } } }+ /**+ * Loads Google Translate element.js on demand (picker open or non-English restore).+ * Not injected from index.html so English first paint stays free of the third-party script.+ */+ ensureGoogleTranslateLoaded(): Promise<void> {+ if (!this.isBrowser) {+ return Promise.resolve();+ }++ if (typeof google !== 'undefined' && google?.translate?.TranslateElement) {+ this.initTranslateElement();+ return Promise.resolve();+ }++ if (this.translateScriptLoading) {+ return this.translateScriptLoading;+ }++ this.translateScriptLoading = new Promise<void>((resolve, reject) => {+ const existing = document.querySelector(+ 'script[data-google-translate]'+ ) as HTMLScriptElement | null;+ if (existing) {+ existing.addEventListener('load', () => {+ this.initTranslateElement();+ resolve();+ });+ existing.addEventListener('error', () => reject());+ return;+ }++ (window as any).googleTranslateElementInit = () => {+ this.initTranslateElement();+ resolve();+ };++ const script = document.createElement('script');+ script.src =+ '//translate.google.com/translate_a/element.js?cb=googleTranslateElementInit';+ script.async = true;+ script.setAttribute('data-google-translate', 'true');+ script.onerror = () => {+ this.translateScriptLoading = null;+ reject();+ };+ document.body.appendChild(script);+ });++ return this.translateScriptLoading;+ }++ private initTranslateElement(): void {+ try {+ if (+ typeof google === 'undefined' ||+ !google?.translate?.TranslateElement ||+ !document.getElementById('google_translate_element')+ ) {+ return;+ }+ if (document.querySelector('#google_translate_element .goog-te-combo')) {+ return;+ }+ new google.translate.TranslateElement(+ {+ pageLanguage: 'en',+ autoDisplay: false,+ layout: google.translate.TranslateElement.InlineLayout.SIMPLE+ },+ 'google_translate_element'+ );+ } catch (_) {+ // Element may already be initialized+ }+ }+ /** * Waits for Google Translate to initialize, then applies the saved locale. */ private applyOnLoad(langCode: string): void { this.setGoogTransCookie(langCode);- const poll = setInterval(() => {- const combo = document.querySelector('.goog-te-combo') as HTMLSelectElement;- if (combo) {- clearInterval(poll);- combo.value = langCode;- combo.dispatchEvent(new Event('change'));- }- }, 500);+ void this.ensureGoogleTranslateLoaded().then(() => {+ const poll = setInterval(() => {+ const combo = document.querySelector('.goog-te-combo') as HTMLSelectElement;+ if (combo) {+ clearInterval(poll);+ combo.value = langCode;+ combo.dispatchEvent(new Event('change'));+ }+ }, 500);- setTimeout(() => clearInterval(poll), 10000);+ setTimeout(() => clearInterval(poll), 10000);+ }); } get currentLocale(): string { return this.localeSubject.value; } /** * Sets the active locale:src/app/core/i18n/locale.service.ts@@ -145,17 +224,19 @@ export class LocaleService { /** * Re-applies the current locale after Angular route changes. * Call from app.component on NavigationEnd. */ reapplyTranslation(): void { if (!this.isBrowser) return; const locale = this.currentLocale; if (locale && locale !== LOCALE_CONSTANTS.DEFAULT_LOCALE) {- setTimeout(() => this.triggerGoogleTranslate(locale), 300);+ void this.ensureGoogleTranslateLoaded().then(() => {+ setTimeout(() => this.triggerGoogleTranslate(locale), 300);+ }); } } /** * Clears saved locale on logout, re-detects browser language, * and reloads if translation needs to change. */ resetOnLogout(): void {
The profile used to wait for the browser, then fire posts, merch, welcome video, ads, and tracking in the same turn as the card. LCP is the banner and bio.
Paint the card first
- Real profile markup renders when
isBasicProfileLoadedis true, including during SSR. It no longer requirescs.isBrowser. - The hidden prerender-only h1/bio stub is used only when the server still does not have basic profile data.
- The user GET uses
CacheTypes.PRERENDEREDon server/prerender; the browser still usesREFRESH_EVERY_N. scheduleAfterFirstPaint()runs work afterafterNextRenderplussetTimeout(0). It no-ops on the server and during the prerender build.- Posts, merch, auto-follow, white-label referral, topic tags, welcome video, and profile-view tracking are scheduled there.
- The banner is
priorityand no longerloading=lazy.
src/app/modules/shared/components/profile/profile.component.html@@ -1,9 +1,9 @@-@if (cs.isBrowser && isBasicProfileLoaded) {+@if (isBasicProfileLoaded) { <div class="main-content"> <div class="inner-content"> @if (!isMyProfile && !isWhiteLabelWebsiteOrEmbedWLSMode) { <div class="myprofile-head profile-head"> @if ( authService?.isLogin || (!authService?.isLogin && !isWhiteLabelWebsiteOrEmbedWLSMode) ) {src/app/modules/shared/components/profile/profile.component.html@@ -93,23 +93,23 @@ class="profile-card-banner" [ngClass]="{ 'default-banner-img': !userProfile?.banner_image_url }" > @if (userProfile?.banner_image_url) { <img alt="banner-image" appDisableRightClick appMobileOptimization+ [priority]="true" [originalUrl]="userProfile?.banner_image_url" [src]="userProfile?.banner_image_url" [width]="800" [height]="400" [format]="'webp'" class="img-fluid"- loading="lazy" decoding="async" /> } <div class="profile-card-action"> @if ( isMyProfile && (permissionKeys.EDIT_MY_PREFERENCES | hasPermission: PERMISSION_TYPE.READ : truesrc/app/modules/shared/components/profile/profile.component.html@@ -5683,14 +5694,14 @@ #unlockUserModal [headerLabel]="PROFILE_CONSTANT.UNLOCK_USER.HEADER" [bodyContent]="PROFILE_CONSTANT.UNLOCK_USER.MESSAGE" [confirmBtnLabel]="CommonEnums.OK" [modalOptions]="{ backdrop: 'static' }" ></app-alert-modal>}-@if (!cs.isBrowser) {+@if (!cs.isBrowser && !isBasicProfileLoaded) { <div class="opacity-0 only-for-prerender"> <h1>{{ userProfile?.first_name }} {{ userProfile?.last_name }}</h1> <p>{{ userProfile?.bio }}</p> </div>}
scheduleAfterFirstPaint and the user GET
This hunk also appears on Timeouts. Same block adds the deferred, timed-out profile-view POST.
src/app/modules/shared/components/profile/profile.component.ts@@ -16,17 +16,18 @@ import { Inject, Input, OnDestroy, OnInit, TemplateRef, ViewChild, DOCUMENT, input,- viewChild+ viewChild,+ afterNextRender} from '@angular/core';import { Store } from '@ngrx/store';import { NgbModal } from '@ng-bootstrap/ng-bootstrap';import { APIENDPOINT, API_URLS, CREATE_POST_TYPE } from '@core/api-url.enum';import { CommonService } from '@shared/services/common.service';import { ApiCallService } from '@services/api-call.service';import { CacheService } from '@shared/services/cache.service';import * as fromUserProfile from '@shared/user-profile.reducer';src/app/modules/shared/components/profile/profile.component.ts@@ -1251,43 +1250,45 @@ export class ProfileComponent implements OnInit, AfterViewInit, OnDestroy { ) { this.isPrivate = !is_follower; } else { this.isPrivate = false; } } if (this.cs.isBrowser) {- if (this.authService.isLogin && !this.userProfile?.is_private) {- this.cs.automaticFollowUser();- if (this.cs.isWhiteLabelWeb) {- this.triggerWhiteLabelReferral();+ this.scheduleAfterFirstPaint(() => {+ if (this.authService.isLogin && !this.userProfile?.is_private) {+ this.cs.automaticFollowUser();+ if (this.cs.isWhiteLabelWeb) {+ this.triggerWhiteLabelReferral();+ } }- }- this.userPosts = [];- this.loadedPostPageNo = Application.ZERO;- if (!this.isPrivate) {- this.getMerchandiseListOfUser();- if (- this.paramsTab &&- this.paramsTab !== PROFILE_CONSTANT.WELCOME_VIDEO- ) {- this.filterPost(this.paramsTab);- this.paramsTab = '';- } else if (- this.filterPostType &&- this.filterPostType !== POST_TYPE_FILTER_LABELS.ALL_TYPES_VALUE- ) {- this.filterPost(this.filterPostType);+ this.userPosts = [];+ this.loadedPostPageNo = Application.ZERO;+ if (!this.isPrivate) {+ this.getMerchandiseListOfUser();+ if (+ this.paramsTab &&+ this.paramsTab !== PROFILE_CONSTANT.WELCOME_VIDEO+ ) {+ this.filterPost(this.paramsTab);+ this.paramsTab = '';+ } else if (+ this.filterPostType &&+ this.filterPostType !== POST_TYPE_FILTER_LABELS.ALL_TYPES_VALUE+ ) {+ this.filterPost(this.filterPostType);+ } else {+ this.getOtherPosts(this.userProfile?.gsi4pk);+ } } else {- this.getOtherPosts(this.userProfile?.gsi4pk);+ this.isLoader = false; }- } else {- this.isLoader = false;- }+ }); } } private triggerWhiteLabelReferral() { const [recruiterPart, creatorPart] = this.cs .getValueFromLocalStorage(Application.RECRUITER_STORAGE_KEY) ?.split('&') ?? [];src/app/modules/shared/components/profile/profile.component.ts@@ -1340,36 +1341,59 @@ export class ProfileComponent implements OnInit, AfterViewInit, OnDestroy { name: `${user?.['data'].first_name} ${user?.['data'].last_name}`, location: user?.['data'].general_location }; } getUserProfile(userId) { this.isSuspendedOrPaused = false; this.cs.setUserId(userId);+ const cacheType =+ this.platformService.isServer || this.platformService.isPrerendered+ ? CacheTypes.PRERENDERED+ : CacheTypes.REFRESH_EVERY_N; this.apiCacheService- .get(APIENDPOINT.user(userId), CacheTypes.REFRESH_EVERY_N)+ .get(APIENDPOINT.user(userId), cacheType) .pipe(takeUntil(this.destroy$)) .subscribe({ next: (res) => { this.handleUserProfileResponse(res); if (!this.isMyProfile && this.cs.isBrowser) {- this.apiCallService- .post(APIENDPOINT.PROFILE_VIEW(res.data?.gsi4pk), {}, true)- .pipe(takeUntil(this.destroy$))- .subscribe();+ this.scheduleAfterFirstPaint(() => {+ this.apiCallService+ .post(APIENDPOINT.PROFILE_VIEW(res.data?.gsi4pk), {}, true)+ .pipe(+ timeout(8000),+ catchError(() => of(null)),+ takeUntil(this.destroy$)+ )+ .subscribe();+ }); } }, error: (err) => { this.isBasicProfileLoaded = true; this.handleUserProfileError(err, userId); } }); }+ /** Run non-critical profile work after the card/bio can paint (LCP). */+ private scheduleAfterFirstPaint(work: () => void): void {+ if (!this.cs.isBrowser || this.platformService.isPrerendered) {+ return;+ }+ afterNextRender(+ () => {+ setTimeout(work, 0);+ },+ { injector: this._injector }+ );+ }+ private handleUserProfileResponse(res: any) { if (!res.status && (res?.data?.is_paused || res?.data?.is_suspended)) { this.isUserFound = true; this.isBasicProfileLoaded = true; this.isLoader = false; this.checkSuspendAndPauseStatus(res); } else if (res.status) { this.currentProfileUserData = res.data;src/app/modules/shared/components/profile/profile.component.ts@@ -1396,20 +1420,20 @@ export class ProfileComponent implements OnInit, AfterViewInit, OnDestroy { if (this.userProfile.gsi4pk !== this.whiteLabelConfig.user.gsi4pk) { this.selectTab({ id: this.Home_String.AWARDS }); } } this.checkPrivacy(); this.isUserFound = true; this.isSuspendedOrPaused = false;- // Initialize topic tags filter+ // Topic tags are non-critical — load after first paint. if (this.cs.isBrowser) { this.initTopicTagsFilterForm();- this.fetchTopicTags();+ this.scheduleAfterFirstPaint(() => this.fetchTopicTags()); } if (this.userProfile.is_deleted) { this.router.navigate(['/']); } this.cs.setValueInocalStorage('gsi4pk', JSON.stringify(res.data.gsi4pk)); if (!this.isCreator && this.tabType === this.Home_String.POST_TYPE) { this.tabType = 'following'; }src/app/modules/shared/components/profile/profile.component.ts@@ -1471,17 +1495,19 @@ export class ProfileComponent implements OnInit, AfterViewInit, OnDestroy { (sub) => sub.sk === this.cs.currentSubscriptionPlan.sk ); if (this.cs.status && this.authService.isAuthenticated()) { this.showSelctedSubscription(selectedSub); this.cs.status = false; } } if (this.userProfile?.gsi4pk && !this.platformService.isPrerendered) {- this.getWelcomeVideoMessage(this.userProfile.gsi4pk);+ this.scheduleAfterFirstPaint(() =>+ this.getWelcomeVideoMessage(this.userProfile.gsi4pk)+ ); } } else { this.isBasicProfileLoaded = true; this.router.navigate(['/not-found']); } } private handleUserProfileError(_err: any, userId: string) {
Welcome video and the right sidebar are heavy standalone components. Leaving them in the profile imports array puts them on the first JS of every profile view.
Welcome video and sidebar
- Both are wrapped in
@defer (on viewport; prefetch on idle), so Angular emits separate chunks. - They are removed from the profile
importsarray. That is required for@deferto lazy-load them. - The sidebar keeps a right-sidebar-skeleton placeholder. Welcome video has no placeholder.
src/app/modules/shared/components/profile/profile.component.ts@@ -220,21 +222,19 @@ import { FollowingsComponent } from '@shared/components/followings/followings.coimport { FundContributeModalComponent } from '@shared/components/fund-contribute-modal/fund-contribute-modal.component';import { FundraiserProgressSectionComponent } from '@shared/components/fundraiser-progress-section/fundraiser-progress-section.component';import { GiveawayResponseModal } from '@shared/components/giveaway-response-modal/giveaway-response-modal.component';import { LoaderComponent } from '@shared/components/loader/loader.component';import { PollOptionsComponent } from '@shared/components/poll-options/poll-options.component';import { PostCardFooterComponent } from '@shared/components/post-card/components/post-card-footer/post-card-footer.component';import { PostCardStatsComponent } from '@shared/components/post-card/components/post-card-stats/post-card-stats.component';import { SensitiveOverlayComponent } from '@shared/components/post-card/components/sensitive-overlay/sensitive-overlay.component';-import { ProfileSidebarComponent } from '@shared/components/profile-sidebar/profile-sidebar.component';import { UserImageComponent } from '@shared/components/user-image/user-image.component';import { VideoPlayerV2Component } from '@shared/components/video-player-v2/video-player-v2.component';import { CustomWelcomeModalTemplate } from '@shared/components/welcome-video-modal/welcome-video-modal.component';-import { WelcomeVideoPostComponent } from '@shared/components/welcome-video-post/welcome-video-post.component';import { AudioSliderPatchDirective } from '@shared/directive/audio-slider-patch.directive';import { DisableRightClickDirective } from '@shared/directive/disable-right-click.directive';import { IntersectionObserverDirective } from '@shared/directive/intersection-observer.directive';import { MobileBridgeFileInputDirective } from '@shared/directive/mobile-bridge-file-input.directive';import { ToolTipDirective } from '@shared/directive/tool-tip.directive';import { TranslateDirective } from '@shared/directive/translate.directive';import { AudioPlayer } from '@pipes/audio-player.pipe';import { CapitalizeFirstPipe } from '@pipes/capitalizeFirst.pipe';src/app/modules/shared/components/profile/profile.component.ts@@ -295,22 +295,20 @@ import { MobileOptimizationDirective } from '@app-shared/directives/mobile-optim FundraiserProgressSectionComponent, GiveawayResponseModal, ImageOverlayComponent, LoaderComponent, PollOptionsComponent, PostCardFooterComponent, PostCardStatsComponent, SensitiveOverlayComponent,- ProfileSidebarComponent, UserGroupsModalComponent, UserImageComponent, VideoPlayerV2Component, CustomWelcomeModalTemplate,- WelcomeVideoPostComponent, AudioSliderPatchDirective, DisableRightClickDirective, IntersectionObserverDirective, MobileBridgeFileInputDirective, ToolTipDirective, TranslateDirective, AudioPlayer, CapitalizeFirstPipe,
Template: @defer
src/app/modules/shared/components/profile/profile.component.html@@ -664,53 +664,55 @@ <div class="post-content"> @if ( welcomeVideoMessageData && welcomeVideoMessageData?.media_url && ((welcomeVideoMessageData?.is_active && !welcomeVideoMessageData?.admin_marked_disabled) || isMyProfile) ) {- <app-welcome-video-post- [items]="welcomeVideoMessageData"- [userProfile]="userProfile"- [isMyProfile]="isMyProfile"- [isWhiteLabelWebsite]="- isWhiteLabelWebsiteOrEmbedWLSMode- "- [postTypeKey]="postTypeKey"- [scheduleTag]="scheduleTag"- [collabScheduleTag]="collabScheduleTag"- [isLikeDisabled]="isLikeDisabled"- [whiteLabelOwnerEamil]="whiteLabelOwnerEamil"- [ROUTE_PATHS]="ROUTE_PATHS.EDIT_WELCOME_MESSAGE"- (blockUser)="blockUser($event)"- (reportPost)="reportWelcomeVideo($event.item)"- (pinPost)="pinPost($event.item, $event.pinned)"- (onTranslationChanged)="- onTranslationChanged($event.item, $event.event)- "- (openCustomWelcomeModal)="openCustomWelcomeModal()"- (showDeleteWelcomeVideoDialog)="- showDeleteWelcomeVideoDialog()- "- (seeLessMore)="seeWelcomeMesLessMore($event)"- (onComments)="onComments($event.item, $event.isComment)"- (showLikes)="showLikes($event.item)"- (likePost)="likePost($event.event, $event.item)"- (sendTipModal)="sendTipModal($event.item)"- (callParentFn)="parentSubscribePlanFunction()"- (openReferralModal)="- openReferralModal(- $event.type,- $event.item,- $event.isShare- )- "- ></app-welcome-video-post>+ @defer (on viewport; prefetch on idle) {+ <app-welcome-video-post+ [items]="welcomeVideoMessageData"+ [userProfile]="userProfile"+ [isMyProfile]="isMyProfile"+ [isWhiteLabelWebsite]="+ isWhiteLabelWebsiteOrEmbedWLSMode+ "+ [postTypeKey]="postTypeKey"+ [scheduleTag]="scheduleTag"+ [collabScheduleTag]="collabScheduleTag"+ [isLikeDisabled]="isLikeDisabled"+ [whiteLabelOwnerEamil]="whiteLabelOwnerEamil"+ [ROUTE_PATHS]="ROUTE_PATHS.EDIT_WELCOME_MESSAGE"+ (blockUser)="blockUser($event)"+ (reportPost)="reportWelcomeVideo($event.item)"+ (pinPost)="pinPost($event.item, $event.pinned)"+ (onTranslationChanged)="+ onTranslationChanged($event.item, $event.event)+ "+ (openCustomWelcomeModal)="openCustomWelcomeModal()"+ (showDeleteWelcomeVideoDialog)="+ showDeleteWelcomeVideoDialog()+ "+ (seeLessMore)="seeWelcomeMesLessMore($event)"+ (onComments)="onComments($event.item, $event.isComment)"+ (showLikes)="showLikes($event.item)"+ (likePost)="likePost($event.event, $event.item)"+ (sendTipModal)="sendTipModal($event.item)"+ (callParentFn)="parentSubscribePlanFunction()"+ (openReferralModal)="+ openReferralModal(+ $event.type,+ $event.item,+ $event.isShare+ )+ "+ ></app-welcome-video-post>+ } } </div> } @if (!isPrivate) { <div class="profile-tabsHead filter-post-container" [ngClass]="{ 'single-row': !(src/app/modules/shared/components/profile/profile.component.html@@ -5369,26 +5371,35 @@ <div class="sidebar-creator sidebar-profile scr-lg"> <app-right-sidebar-skeleton [showAdSkeleton]="true" [showViewAllButtonSkeleton]="true" ></app-right-sidebar-skeleton> </div> } @if (!isSidebarLoading) {- <app-profile-sidebar- [dataList]="merchandiseList"- [userProfile]="userProfile"- [selfProfile]="isMyProfile"- [isStoreConnected]="(isStoreConnected || hasStoreItems) && !isPrivate"- [showUpcomingEventCountdown]="showUpcomingEventCountdown"- [upcomingEventDetails]="upcomingEventDetails"- [creatorEventName]="creatorEventDetails?.[0]?.event_calendar_name"- (goToDownloadableStore)="viewStore(Fields.downloadablesType)"- ></app-profile-sidebar>+ @defer (on viewport; prefetch on idle) {+ <app-profile-sidebar+ [dataList]="merchandiseList"+ [userProfile]="userProfile"+ [selfProfile]="isMyProfile"+ [isStoreConnected]="(isStoreConnected || hasStoreItems) && !isPrivate"+ [showUpcomingEventCountdown]="showUpcomingEventCountdown"+ [upcomingEventDetails]="upcomingEventDetails"+ [creatorEventName]="creatorEventDetails?.[0]?.event_calendar_name"+ (goToDownloadableStore)="viewStore(Fields.downloadablesType)"+ ></app-profile-sidebar>+ } @placeholder {+ <div class="sidebar-creator sidebar-profile scr-lg">+ <app-right-sidebar-skeleton+ [showAdSkeleton]="true"+ [showViewAllButtonSkeleton]="true"+ ></app-right-sidebar-skeleton>+ </div>+ } } } </div>} @else { <app-profile-skeleton></app-profile-skeleton>}<div #ViewSubscriptionModal="bs-modal"
Side requests fail closed
A hung merch, welcome-video, posts, ads, or topic-tags call used to keep the profile in a bad state or surface a raw error. Those calls now fail closed after 8 seconds.
timeout(8000)pluscatchErroron topic tags, profile-view POST, other posts, skyscraper ads, welcome video, and merchandise.- Error toasts only show when the response actually has a message.
- The
getUserProfilehunk below also contains thePRERENDEREDcache-type change from Profile.
timeout(8000) on the side path
src/app/modules/shared/components/profile/profile.component.ts@@ -54,17 +55,18 @@ import { forkJoin, of} from 'rxjs';import { map, catchError, distinctUntilChanged, debounceTime,- finalize+ finalize,+ timeout} from 'rxjs/operators';import { DialogBoxComponent } from '@shared/components/dialog-box/dialog-box.component';import { DashboardModuleService } from '@modules/dashboard/services/dashboard-module.service';import { Messages } from '@enums/messages.enums';import { UploadService } from '@services/upload.service';import { ActivatedRoute, Router, RouterLink } from '@angular/router';import { CommonModule } from '@angular/common';import { UnfollowDialogComponent } from '@shared/components/unfollow-dialog/unfollow-dialog.component';src/app/modules/shared/components/profile/profile.component.ts@@ -1197,30 +1195,31 @@ export class ProfileComponent implements OnInit, AfterViewInit, OnDestroy { return; } this.apiCallService .get( `${API_URLS.postTopics}?creator_id=${this.userProfile.gsi4pk}`, false )- .pipe(takeUntil(this.destroy$))+ .pipe(+ timeout(8000),+ catchError(() => of(null)),+ takeUntil(this.destroy$)+ ) .subscribe({ next: (res) => {- if (res.status && res.data?.length) {+ if (res?.status && res.data?.length) { this.topicTags = res.data.map((tag: any) => ({ sk: tag.sk, title: tag.title, pk: tag.pk, id: tag.id })); }- },- error: (err) => {- this.cs.showErrorMessage(err.message); } }); } navigateToForum(url) { if (this.authService.isAuthenticated()) { const redirectUrl = this.isMyProfile ? [url]src/app/modules/shared/components/profile/profile.component.ts@@ -1340,36 +1341,59 @@ export class ProfileComponent implements OnInit, AfterViewInit, OnDestroy { name: `${user?.['data'].first_name} ${user?.['data'].last_name}`, location: user?.['data'].general_location }; } getUserProfile(userId) { this.isSuspendedOrPaused = false; this.cs.setUserId(userId);+ const cacheType =+ this.platformService.isServer || this.platformService.isPrerendered+ ? CacheTypes.PRERENDERED+ : CacheTypes.REFRESH_EVERY_N; this.apiCacheService- .get(APIENDPOINT.user(userId), CacheTypes.REFRESH_EVERY_N)+ .get(APIENDPOINT.user(userId), cacheType) .pipe(takeUntil(this.destroy$)) .subscribe({ next: (res) => { this.handleUserProfileResponse(res); if (!this.isMyProfile && this.cs.isBrowser) {- this.apiCallService- .post(APIENDPOINT.PROFILE_VIEW(res.data?.gsi4pk), {}, true)- .pipe(takeUntil(this.destroy$))- .subscribe();+ this.scheduleAfterFirstPaint(() => {+ this.apiCallService+ .post(APIENDPOINT.PROFILE_VIEW(res.data?.gsi4pk), {}, true)+ .pipe(+ timeout(8000),+ catchError(() => of(null)),+ takeUntil(this.destroy$)+ )+ .subscribe();+ }); } }, error: (err) => { this.isBasicProfileLoaded = true; this.handleUserProfileError(err, userId); } }); }+ /** Run non-critical profile work after the card/bio can paint (LCP). */+ private scheduleAfterFirstPaint(work: () => void): void {+ if (!this.cs.isBrowser || this.platformService.isPrerendered) {+ return;+ }+ afterNextRender(+ () => {+ setTimeout(work, 0);+ },+ { injector: this._injector }+ );+ }+ private handleUserProfileResponse(res: any) { if (!res.status && (res?.data?.is_paused || res?.data?.is_suspended)) { this.isUserFound = true; this.isBasicProfileLoaded = true; this.isLoader = false; this.checkSuspendAndPauseStatus(res); } else if (res.status) { this.currentProfileUserData = res.data;src/app/modules/shared/components/profile/profile.component.ts@@ -1627,28 +1653,34 @@ export class ProfileComponent implements OnInit, AfterViewInit, OnDestroy { cacheKey, {} ); const skyscraperAds$ = this.getSkyscraperAdsObservable( SPONSORED_AD_CONSTANTS.REQUEST_FROM_PROFILE_FEED ); forkJoin({- otherPosts: otherPosts$,- skyscraperAds: skyscraperAds$+ otherPosts: otherPosts$.pipe(+ timeout(8000),+ catchError(() => of({ status: false, data: [], message: null }))+ ),+ skyscraperAds: skyscraperAds$.pipe(+ timeout(8000),+ catchError(() => of([]))+ ) }) .pipe(takeUntil(this.destroy$)) .subscribe({ next: (results) => { const res = results.otherPosts;- if (res.status) {+ if (res?.status) { // Store skyscraper ads this.skyscraperAds = results.skyscraperAds; // Merge ads with posts- const updatedPosts = this.insertSkyscraperAds([...res.data]);+ const updatedPosts = this.insertSkyscraperAds([...(res.data || [])]); this.isLoader = false; this.userPosts = updatedPosts; this.loadedPostPageNo = Application.ONE; // this.userProfile.post_count = res.data.length; this.postPages = res.LastEvaluatedKey || { pk: '', sk: '' }; if (this.authService.isAuthenticated()) { this.fetchPostStatus();src/app/modules/shared/components/profile/profile.component.ts@@ -1657,23 +1689,29 @@ export class ProfileComponent implements OnInit, AfterViewInit, OnDestroy { } this.restoreUnlockReturnIfNeeded(); this.restoreScrolledPostIfNeeded(); this.isFirstLoad = false; } else { this.isLoader = false; this.loadedPostPageNo = Application.ZERO; this.postPages = { pk: '', sk: '' };- this.cs.showErrorMessage(res.message);+ const message = res?.message;+ if (message) {+ this.cs.showErrorMessage(message);+ } } }, error: (err) => { this.isLoader = false; this.loadedPostPageNo = Application.ZERO;- this.cs.showErrorMessage(err.message);+ const message = err?.error?.message ?? err?.message;+ if (message) {+ this.cs.showErrorMessage(message);+ } } }); } onMentionUserSelect = (selection) => { this.setTeggedUsers(selection); return `@${selection.first_name} ${selection.last_name} `; };
Welcome video and merch
src/app/modules/shared/components/profile/profile.component.ts@@ -2006,23 +2044,29 @@ export class ProfileComponent implements OnInit, AfterViewInit, OnDestroy { post.description = post.cta_title; } }); } getWelcomeVideoMessage(id: string): void { const url = `${API_URLS.welcomeMessage}/${id}`; this.apiCacheService .get(url, CacheTypes.REFRESH_EVERY_N)- .pipe(takeUntil(this.destroy$))+ .pipe(+ timeout(8000),+ catchError((err) => {+ this.handleWelcomeVideoError(err);+ return of(null);+ }),+ takeUntil(this.destroy$)+ ) .subscribe({ next: (res) => {- this.handleWelcomeVideoResponse(res);- },- error: (err) => {- this.handleWelcomeVideoError(err);+ if (res) {+ this.handleWelcomeVideoResponse(res);+ } } }); } private handleWelcomeVideoResponse(res: any) { if (res.status) { this.welcomeVideoMessageData = { ...res?.data,src/app/modules/shared/components/profile/profile.component.ts@@ -2036,17 +2080,20 @@ export class ProfileComponent implements OnInit, AfterViewInit, OnDestroy { this.cs.isBrowser ) { this.openCustomWelcomeModal(); this.paramsTab = ''; } } private handleWelcomeVideoError(err: any) {- this.cs.showErrorMessage(err.message);+ const message = err?.error?.message ?? err?.message;+ if (message) {+ this.cs.showErrorMessage(message);+ } } fetchStatusForSelectedPost(idx) { this.userPosts.forEach((el, id) => { if (idx === id && el.is_locked) { el.media_url = el?.lock_status?.media_url ? el?.lock_status?.media_url : el.media_url; el.is_locked = !el?.lock_status?.is_unlocked;src/app/modules/shared/components/profile/profile.component.ts@@ -4218,27 +4265,31 @@ export class ProfileComponent implements OnInit, AfterViewInit, OnDestroy { if (!this.userProfile?.is_white_label || this.userProfile?.is_host_owner) { this.isSidebarLoading = true; this.apiCacheService .get( APIENDPOINT.getFeatureListOfUser(this.userProfile?.gsi4pk), CacheTypes.REFRESH_EVERY_N ) .pipe(+ timeout(8000),+ catchError((err) => {+ this.handleMerchandiseError(err);+ return of(null);+ }), takeUntil(this.destroy$), finalize(() => { this.isSidebarLoading = false; }) ) .subscribe({ next: (res) => {- this.handleMerchandiseResponse(res);- },- error: (err) => {- this.handleMerchandiseError(err);+ if (res) {+ this.handleMerchandiseResponse(res);+ } } }); } } private handleMerchandiseResponse(res: any) { if (res.status) { this.isStoreConnected = res.is_store_connected;src/app/modules/shared/components/profile/profile.component.ts@@ -4250,17 +4301,20 @@ export class ProfileComponent implements OnInit, AfterViewInit, OnDestroy { this.checkHasStoreItems(); } } } private handleMerchandiseError(err: any) { this.merchandiseList = []; this.hasStoreItems = false;- this.cs.showErrorMessage(err.message);+ const message = err?.error?.message ?? err?.message;+ if (message) {+ this.cs.showErrorMessage(message);+ } } private checkHasStoreItems(): void { const userId = this.userProfile?.gsi4pk; if (!userId) { return; }
Smaller images, less layout shift
The mobile-optimization directive was generating a 2x srcset whenever the original was large enough. That wastes bytes on the LCP image and posters. The loaded skyscraper card is 500px tall; the skeleton was shorter.
- 2x srcset is skipped for priority/LCP images. The size cap drops from 1000px to 640px.
- The marketing header logo now requests 290×32 webp.
- Both profile-skeleton and right-sidebar-skeleton give the ad slot a 500px min-height.
src/app/modules/profile/component/marketing-page-layout-container/header-layout/header-layout.component.html@@ -60,16 +60,19 @@ " [src]="logoUrl" [alt]="CONST.LOGO_ALT" loading="eager" decoding="async" appDisableRightClick appMobileOptimization [originalUrl]="logoUrl"+ [width]="290"+ [height]="32"+ [format]="'webp'" /> } } </div> <nav class="header-layout__nav" aria-label="Header menu"> @for (item of menuItems; track item) { @if (item.isWelcomeVideo) {src/app/shared/directives/mobile-optimization.directive.ts@@ -175,20 +175,22 @@ export class MobileOptimizationDirective // Build srcset for responsive images - only add 2x if dimensions are small enough let srcset = optimized; try { // Only generate 2x variant for high-DPI if: // 1. Original width is known AND // 2. Original is at least 2x the requested size AND // 3. Requested size is reasonable (avoid excessive upscaling)+ // Skip 2x for LCP/priority images and posters — oversized srcset wastes bytes. const shouldGenerate2x =+ !this.priority() && this._origWidth && this._origWidth >= (widthToRequest ?? 0) * 2 &&- (widthToRequest ?? 0) < 1000; // Arbitrary limit to avoid huge requests+ (widthToRequest ?? 0) < 640; if (shouldGenerate2x && !this.isPoster()) { // Only generate 2x for regular images, not for posters const optimized2x = this.mobileOptimizationService.getOptimizedImageUrl( sourceUrlString, widthToRequest ? Math.round((widthToRequest ?? 0) * 2) : undefined, heightToRequest ? Math.round((heightToRequest ?? 0) * 2) : undefined, qualityNum !== undefined ? qualityNum : undefined,
Ad skeleton min-height
src/app/components/skeleton/profile-skeleton/profile-skeleton.component.scss@@ -150,10 +150,18 @@ @include breakpoints(mini-ipad) { margin-top: 12px; } &__ad { width: 100%; margin-top: 8px; margin-bottom: 20px;+ // Match loaded skyscraper card (min-height 500px) to avoid desktop CLS.+ min-height: 500px;++ app-skeleton {+ display: block;+ width: 100%;+ min-height: 500px;+ } }}src/app/components/skeleton/right-sidebar-skeleton/right-sidebar-skeleton.component.scss@@ -65,10 +65,18 @@ app-skeleton { display: block; } } &__ad { width: 100%; margin-bottom: 20px;+ // Match loaded skyscraper card (min-height 500px) to avoid desktop CLS.+ min-height: 500px;++ app-skeleton {+ display: block;+ width: 100%;+ min-height: 500px;+ } }}
styles.scss imported the full Bootstrap barrel. Navbar, offcanvas, spinners, list-group, and placeholders never ship as used UI.
The curated stack
src/assets/scss/_bootstrap-partials.scss@@ -0,0 +1,38 @@+// Bootstrap SCSS diet: only modules this app uses (drops navbar, list-group,+// spinners, offcanvas, placeholders). Keep in sync with class usage audits.+@import 'bootstrap/scss/functions';+@import 'bootstrap/scss/variables';+@import 'bootstrap/scss/variables-dark';+@import 'bootstrap/scss/maps';+@import 'bootstrap/scss/mixins';+@import 'bootstrap/scss/utilities';++@import 'bootstrap/scss/root';+@import 'bootstrap/scss/reboot';+@import 'bootstrap/scss/type';+@import 'bootstrap/scss/images';+@import 'bootstrap/scss/containers';+@import 'bootstrap/scss/grid';+@import 'bootstrap/scss/tables';+@import 'bootstrap/scss/forms';+@import 'bootstrap/scss/buttons';+@import 'bootstrap/scss/transitions';+@import 'bootstrap/scss/dropdown';+@import 'bootstrap/scss/button-group';+@import 'bootstrap/scss/nav';+@import 'bootstrap/scss/card';+@import 'bootstrap/scss/accordion';+@import 'bootstrap/scss/breadcrumb';+@import 'bootstrap/scss/pagination';+@import 'bootstrap/scss/badge';+@import 'bootstrap/scss/alert';+@import 'bootstrap/scss/progress';+@import 'bootstrap/scss/close';+@import 'bootstrap/scss/toasts';+@import 'bootstrap/scss/modal';+@import 'bootstrap/scss/tooltip';+@import 'bootstrap/scss/popover';+@import 'bootstrap/scss/carousel';++@import 'bootstrap/scss/helpers';+@import 'bootstrap/scss/utilities/api';src/styles.scss@@ -1,10 +1,9 @@@use 'sass:map';-@use 'bootstrap/scss/bootstrap';@use 'tools' as *;@use 'main';@use './app/components/skeleton/skeleton';@font-face { font-family: 'Satoshi'; src: url('./assets/fonts/Satoshi-Regular.otf') format('opentype'); font-weight: 400;src/assets/scss/_icon-lite.scss@@ -4,17 +4,17 @@ src: url('../fonts/icomoon-lite.eot?r4a7ko'); src: url('../fonts/icomoon-lite.eot?r4a7ko#iefix') format('embedded-opentype'), url('../fonts/icomoon-lite.woff2?r4a7ko') format('woff2'), url('../fonts/icomoon-lite.ttf?r4a7ko') format('truetype'), url('../fonts/icomoon-lite.woff?r4a7ko') format('woff'), url('../fonts/icomoon-lite.svg?r4a7ko#icomoon-lite') format('svg'); font-weight: normal; font-style: normal;- font-display: block;+ font-display: swap;}[class^="icon-lite"], [class*=" icon-lite"] { /* use !important to prevent issues with browser extensions that change fonts */ font-family: 'icomoon-lite' !important; speak: never; font-style: normal; font-weight: normal;
angular.json and generated tenants
The generate-whitelabel hunk also appears on S3. Same block forces critical-CSS inlining on prod/qa.
angular.json@@ -70,16 +70,17 @@ { "glob": "**/*", "input": "node_modules/flag-icon-css/flags", "output": "/flags" } ], "styles": [ "node_modules/@ng-select/ng-select/themes/default.theme.css",+ "src/assets/scss/_bootstrap-partials.scss", "src/styles.scss", "src/assets/scss/_white-label-theme.scss", { "input": "node_modules/intl-tel-input/build/css/intlTelInput.css", "bundleName": "lazy-intl-tel-input", "inject": false }, {angular.json@@ -369,16 +370,17 @@ ], "styles": [ "node_modules/intl-tel-input/build/css/intlTelInput.css", "node_modules/@angular/material/prebuilt-themes/indigo-pink.css", "node_modules/@ng-select/ng-select/themes/default.theme.css", "node_modules/ngx-bootstrap/datepicker/bs-datepicker.css", "node_modules/ngx-owl-carousel-o/lib/styles/prebuilt-themes/owl.carousel.min.css", "node_modules/ngx-owl-carousel-o/lib/styles/prebuilt-themes/owl.theme.default.min.css",+ "src/assets/scss/_bootstrap-partials.scss", "src/styles.scss", "node_modules/quill/dist/quill.snow.css", "node_modules/ngx-toastr/toastr.css", "node_modules/primeicons/primeicons.css", "node_modules/quill-emoji/dist/quill-emoji.css", "node_modules/@ctrl/ngx-emoji-mart/picker.css" ], "stylePreprocessorOptions": {scripts/generate-whitelabel.js@@ -688,25 +688,59 @@ function createProjectConfig(userName, baseProject, targetEnv = null) { } // Application builder writes browser/ and server/ under this parent path if (newProject.architect && newProject.architect.build && newProject.architect.build.options) { newProject.architect.build.options.outputPath = `dist/${userName}`; // Add white-label-theme-entry.scss to styles array if (newProject.architect.build.options.styles && Array.isArray(newProject.architect.build.options.styles)) {+ const styles = newProject.architect.build.options.styles;+ const bootstrapPartialsPath = 'src/assets/scss/_bootstrap-partials.scss';+ const mainStylesPath = 'src/styles.scss';+ if (!styles.includes(bootstrapPartialsPath)) {+ const mainIdx = styles.indexOf(mainStylesPath);+ if (mainIdx >= 0) {+ styles.splice(mainIdx, 0, bootstrapPartialsPath);+ } else {+ styles.unshift(bootstrapPartialsPath);+ }+ console.log(`✅ Added bootstrap-partials.scss to styles for ${userName}`);+ } const whiteLabelThemePath = 'src/assets/scss/_white-label-theme.scss'; // Only add if it doesn't already exist- if (!newProject.architect.build.options.styles.includes(whiteLabelThemePath)) {- newProject.architect.build.options.styles.push(whiteLabelThemePath);+ if (!styles.includes(whiteLabelThemePath)) {+ styles.push(whiteLabelThemePath); console.log(`✅ Added white-label-theme-entry.scss to styles for ${userName}`); } } }+ // Keep critical-CSS inlining on prod/qa (media=print + onload is intentional, not a duplicate stylesheet).+ const buildConfigs = newProject.architect?.build?.configurations;+ if (buildConfigs) {+ for (const name of ['production', 'qa']) {+ const cfg = buildConfigs[name];+ if (!cfg) continue;+ if (cfg.optimization === true || cfg.optimization == null) {+ cfg.optimization = {+ scripts: true,+ styles: { minify: true, inlineCritical: true },+ fonts: true+ };+ } else if (typeof cfg.optimization === 'object') {+ cfg.optimization.styles = {+ minify: true,+ ...(cfg.optimization.styles || {}),+ inlineCritical: true+ };+ }+ }+ }+ retargetArchitectProjectReferences(newProject.architect, userName); // Determine which environments to process const envsToProcess = targetEnv ? [targetEnv] : CONFIG.environments; // Update ALL build configurations with proper fileReplacements if (newProject.architect && newProject.architect.build && newProject.architect.build.configurations) { // First, update existing configurations (production, development, qa, staging, etc.)
Uploads stamped Cache-Control: public,max-age=31536000,immutable on the whole dist, including index.html and prerendered HTML. Browsers could keep a stale shell for a year.
rclone-upload-dist.sh
- Two rclone copy passes: everything except HTML as immutable, then only
index.html/**/index.htmlasmax-age=0,must-revalidate. - buildspec and the three tenant upload scripts call this helper.
- Concurrency stays conservative (
transfers=2,checkers=4). - explore, splash, manifest, favicon, and sitemaps stay excluded.
deployment_script/rclone-upload-dist.sh@@ -0,0 +1,56 @@+#!/usr/bin/env bash+# Upload a browser dist folder to S3 with split Cache-Control:+# - HTML (index.html / prerendered **/index.html): no-cache / must-revalidate+# - Hashed assets and everything else: long-lived immutable+set -euo pipefail++OUTPUT_PATH="${1:?usage: rclone-upload-dist.sh <outputPath> <s3Bucket>}"+BUCKET="${2:?usage: rclone-upload-dist.sh <outputPath> <s3Bucket>}"++COMMON_EXCLUDES=(+ --exclude "explore/**"+ --exclude "assets/splashscreen/**"+ --exclude "assets/Json/manifest.json"+ --exclude "assets/Json/splashscreens.json"+ --exclude "assets/favicon/**"+ --exclude "favicon.ico"+ --exclude "sitemap.xml"+ --exclude "sitemap-*.xml"+)++LOG_FILE="${RCLONE_LOG_FILE:-rclone-${BUCKET}.log}"++TRANSFERS="${RCLONE_TRANSFERS:-2}"+CHECKERS="${RCLONE_CHECKERS:-4}"++echo "===== UPLOADING ASSETS (immutable) TO s3:$BUCKET ====="+rclone copy "$OUTPUT_PATH" ":s3:$BUCKET" \+ "${COMMON_EXCLUDES[@]}" \+ --exclude "**/*.html" \+ --header-upload "Cache-Control:public,max-age=31536000,immutable" \+ --s3-env-auth \+ --checksum \+ --transfers="$TRANSFERS" \+ --checkers="$CHECKERS" \+ --retries=3 \+ --retries-sleep=5s \+ --log-level="${RCLONE_LOG_LEVEL:-INFO}" \+ --log-file="$LOG_FILE"++echo "===== UPLOADING HTML (must-revalidate) TO s3:$BUCKET ====="+rclone copy "$OUTPUT_PATH" ":s3:$BUCKET" \+ --include "index.html" \+ --include "**/index.html" \+ --exclude "*" \+ --header-upload "Cache-Control:public,max-age=0,must-revalidate" \+ --s3-env-auth \+ --checksum \+ --transfers="$TRANSFERS" \+ --checkers="$CHECKERS" \+ --retries=3 \+ --retries-sleep=5s \+ --log-level="${RCLONE_LOG_LEVEL:-INFO}" \+ --log-file="$LOG_FILE"++echo "===== RCLONE LOG ($BUCKET) ====="+cat "$LOG_FILE" || true
Callers
buildspec-s3.yml@@ -39,30 +39,21 @@ phases: echo "===== $BUILD_ENV ENV - SKIPPING NPM AUDIT =====" fi - npm run $BUILD_ENV # Replace API endpoint in shell script - sed -i "s|{{API_ENDPOINT}}|$API_ENDPOINT|g" deployment_script/whitelabel_invalidate.sh- # Upload static assets (non-HTML) with long-term cache- - echo "===== UPLOADING STATIC ASSETS TO S3 (LONG CACHE) ====="+ # Upload: hashed assets immutable; HTML must-revalidate+ - echo "===== UPLOADING DIST TO S3 (SPLIT CACHE) =====" - |- rclone copy dist/TopFans/browser/ :s3:$ENV_S3 \- --exclude "explore/**" \- --header-upload "Cache-Control:public,max-age=31536000,immutable" \- --s3-env-auth \- --checksum \- --log-level=INFO \- --log-file=rclone-static.log-- # Show upload logs- - echo "===== RCLONE STATIC ASSETS LOG ====="- - cat rclone-static.log+ RCLONE_LOG_FILE=rclone-static.log \+ ./deployment_script/rclone-upload-dist.sh dist/TopFans/browser "$ENV_S3" # Perform full CloudFront invalidation - | echo "===== INVALIDATING CLOUDFRONT: /* =====" aws cloudfront create-invalidation \ --distribution-id "$ENV_CLOUDFRONT" \ --paths "/*" \ --query 'Invalidation.Id' \deployment_script/build_and_upload.sh@@ -28,34 +28,18 @@ for row in "${tenants[@]}"; do echo "===== BUILDING FOR BUCKET: $bucket =====" echo ">>> DEBUG: command=$command" # Run build - will fail the entire script if this fails eval "$command" echo "===== UPLOADING WITH RCLONE TO $bucket ====="- rclone copy "$outputPath" ":s3:$bucket" \- --exclude "explore/**" \- --exclude "assets/splashscreen/**" \- --exclude "assets/Json/manifest.json" \- --exclude "assets/Json/splashscreens.json" \- --exclude "assets/favicon/**" \- --exclude "favicon.ico" \- --exclude "sitemap.xml" \- --exclude "sitemap-*.xml" \- --header-upload "Cache-Control:public,max-age=31536000,immutable" \- --s3-env-auth \- --checksum \- --log-level=DEBUG \- --log-file="rclone-$bucket.log"--- echo "===== RCLONE LOG OUTPUT ($bucket) ====="- cat "rclone-$bucket.log"+ RCLONE_LOG_FILE="rclone-$bucket.log" RCLONE_LOG_LEVEL=DEBUG \+ ./deployment_script/rclone-upload-dist.sh "$outputPath" "$bucket" echo "=============================================" userName=$(echo "$row" | jq -r '.userName // empty') userId=$(echo "$row" | jq -r '.userId // empty') environment=$(echo "$row" | jq -r '.environment // empty') if [[ -z "$userId" || -z "$userName" || -z "$environment" ]]; then echo "ERROR: missing userId/userName/environment for $bucket" exit 1deployment_script/ondemand-build-and-upload.sh@@ -10,33 +10,18 @@ cd "$REPO_ROOT"node scripts/generate-whitelabel.js --env "$WL_ENV" --users "$WL_USERNAME" --user-id "$WL_USER_ID"npm run "prerender-all-${WL_USERNAME}-${WL_ENV}"output_path="${REPO_ROOT}/dist/${WL_USERNAME}/browser"[[ -d "$output_path" ]] || { echo "ERROR: missing build output at $output_path"; exit 1; }-rclone copy "$output_path" ":s3:${WL_S3_BUCKET}" \- --exclude "explore/**" \- --exclude "assets/splashscreen/**" \- --exclude "assets/Json/manifest.json" \- --exclude "assets/Json/splashscreens.json" \- --exclude "assets/favicon/**" \- --exclude "favicon.ico" \- --exclude "sitemap.xml" \- --exclude "sitemap-*.xml" \- --header-upload "Cache-Control:public,max-age=31536000,immutable" \- --s3-env-auth \- --checksum \- --log-level=INFO \- --transfers=2 \- --checkers=4 \- --retries=3 \- --retries-sleep=5s+RCLONE_LOG_FILE="rclone-${WL_S3_BUCKET}.log" \+ "$REPO_ROOT/deployment_script/rclone-upload-dist.sh" "$output_path" "$WL_S3_BUCKET"node scripts/request-wls-build.mjs \ --env="$WL_ENV" \ --user="$WL_USERNAME" \ --userId="$WL_USER_ID" \ --tasks=CONFIG,SPLASH,APP_ICON,MANIFEST,SITEMAPif [[ -n "${WL_CF_DIST:-}" ]]; thendeployment_script/parallel-build-and-upload.sh@@ -77,36 +77,21 @@ process_tenant() { echo "❌ No output at $out_abs after ${WAIT_FOR_OUTPUT_SECONDS}s" ls -la "$REPO_ROOT" || true popd >/dev/null return 21 fi echo "Output ready:" ls -la "$out_abs" || true- # Upload with rclone (conservative concurrency)+ # Upload with rclone (assets immutable; HTML must-revalidate) echo "===== UPLOADING: $bucket from $out_abs =====" set +e- rclone copy "$out_abs" ":s3:$bucket" \- --exclude "explore/**" \- --exclude "assets/splashscreen/**" \- --exclude "assets/Json/manifest.json" \- --exclude "assets/Json/splashscreens.json" \- --exclude "assets/favicon/**" \- --exclude "favicon.ico" \- --exclude "sitemap.xml" \- --exclude "sitemap-*.xml" \- --header-upload "Cache-Control:public,max-age=31536000,immutable" \- --s3-env-auth \- --checksum \- --log-level=INFO \- --transfers=2 \- --checkers=4 \- --retries=3 \- --retries-sleep=5s+ RCLONE_LOG_FILE="rclone-$bucket.log" \+ "$REPO_ROOT/deployment_script/rclone-upload-dist.sh" "$out_abs" "$bucket" rc=$? set -e popd >/dev/null if [[ $rc -ne 0 ]]; then echo "❌ RCLONE FAILED for $bucket (rc=$rc)" return "$rc" fi
Inline critical CSS on white-label prod/qa
Same generate-whitelabel.js hunk as Bootstrap. Repeated because the two edits solve different problems.
scripts/generate-whitelabel.js@@ -688,25 +688,59 @@ function createProjectConfig(userName, baseProject, targetEnv = null) { } // Application builder writes browser/ and server/ under this parent path if (newProject.architect && newProject.architect.build && newProject.architect.build.options) { newProject.architect.build.options.outputPath = `dist/${userName}`; // Add white-label-theme-entry.scss to styles array if (newProject.architect.build.options.styles && Array.isArray(newProject.architect.build.options.styles)) {+ const styles = newProject.architect.build.options.styles;+ const bootstrapPartialsPath = 'src/assets/scss/_bootstrap-partials.scss';+ const mainStylesPath = 'src/styles.scss';+ if (!styles.includes(bootstrapPartialsPath)) {+ const mainIdx = styles.indexOf(mainStylesPath);+ if (mainIdx >= 0) {+ styles.splice(mainIdx, 0, bootstrapPartialsPath);+ } else {+ styles.unshift(bootstrapPartialsPath);+ }+ console.log(`✅ Added bootstrap-partials.scss to styles for ${userName}`);+ } const whiteLabelThemePath = 'src/assets/scss/_white-label-theme.scss'; // Only add if it doesn't already exist- if (!newProject.architect.build.options.styles.includes(whiteLabelThemePath)) {- newProject.architect.build.options.styles.push(whiteLabelThemePath);+ if (!styles.includes(whiteLabelThemePath)) {+ styles.push(whiteLabelThemePath); console.log(`✅ Added white-label-theme-entry.scss to styles for ${userName}`); } } }+ // Keep critical-CSS inlining on prod/qa (media=print + onload is intentional, not a duplicate stylesheet).+ const buildConfigs = newProject.architect?.build?.configurations;+ if (buildConfigs) {+ for (const name of ['production', 'qa']) {+ const cfg = buildConfigs[name];+ if (!cfg) continue;+ if (cfg.optimization === true || cfg.optimization == null) {+ cfg.optimization = {+ scripts: true,+ styles: { minify: true, inlineCritical: true },+ fonts: true+ };+ } else if (typeof cfg.optimization === 'object') {+ cfg.optimization.styles = {+ minify: true,+ ...(cfg.optimization.styles || {}),+ inlineCritical: true+ };+ }+ }+ }+ retargetArchitectProjectReferences(newProject.architect, userName); // Determine which environments to process const envsToProcess = targetEnv ? [targetEnv] : CONFIG.environments; // Update ALL build configurations with proper fileReplacements if (newProject.architect && newProject.architect.build && newProject.architect.build.configurations) { // First, update existing configurations (production, development, qa, staging, etc.)
The performance contract
- Bootstrap is the curated partial, not the full barrel.
- Google Translate loads from
LocaleService, notindex.html. - GTM/GA4 idle-load stays in
gtag.service.ts. - Follow-up, not in this PR:
source-map-exploreron the production bundle, Firebase modular imports, leftover Bootstrap JS. polyfills.tsonly adds a comment: keep zone.js plus@angular/localize.
knowledge/stack.md@@ -59,8 +59,15 @@ Clone an existing feature module (wallet, messages, monetize). Lazy route in `ap## Definition of done (UI slice)1. HTTP uses `ApiCacheService` / `ApiCallService` (no raw `HttpClient` in pages)2. Lazy route registered; path from `ROUTE_PATHS`3. Constants file for user-facing strings4. Matches [web-conventions.md](web-conventions.md)5. Talks to the existing backend via `environment.baseURL`; no assumed endpoints6. `tsc` and lint clean on touched files++## Performance notes++- Bootstrap SCSS is a curated partial stack in `src/assets/scss/_bootstrap-partials.scss` (listed in `angular.json` styles before `styles.scss`), not the full `bootstrap/scss/bootstrap` barrel.+- Google Translate `element.js` loads via `LocaleService.ensureGoogleTranslateLoaded()` (language switcher open or non-`en` restore), not from `index.html`.+- GTM/GA4 idle-load lives in `gtag.service.ts`. Duplicate container IDs are a tenant/`environment` config cleanup, not a second loader.+- Bundle audit (follow-up PRs): `ng build --configuration=production --stats-json` then `npx source-map-explorer dist/TopFans/browser/*.js` — prioritize Firebase modular imports and unused Bootstrap JS.src/polyfills.ts@@ -11,16 +11,20 @@ * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. * * Learn more in https://angular.io/guide/browser-support *//*************************************************************************************************** * BROWSER POLYFILLS+ *+ * Keep only zone.js + @angular/localize on the critical path. Do not add IE/legacy shims+ * or heavy polyfills here without an ADR (zoneless would also need an ADR). Optional+ * Node shims below are for browser libs that expect `global` / `process`. */import 'zone.js'; // Included with Angular CLI.import '@angular/localize/init';// import 'intersection-observer';(window as any).global = window;
Watch on review
- CSS
- Any screen still using dropped Bootstrap modules: navbar, list-group, offcanvas, spinner-*, placeholders.
- Translate
- Non-English first load: element.js starts from LocaleService, so there can be a short untranslated flash even though the googtrans cookie is set early.
- Timeouts
- 8s timeouts swallow slow merch / welcome-video / posts / ads. The user sees an empty section, not an error, unless the body has a message.
- @defer
- No welcome-video placeholder. SSR/hydration and below-the-fold first visit should be clicked through.
- SEO
- Organization JSON-LD and iPhone 17 splash images are gone from index.html. Confirm that was intended.
- Cache
- After deploy, confirm index.html and prerendered routes send must-revalidate, not immutable.
Built from git range 17dd0e9a46...cb09e9d27b (pull/10977).