From 93e337b9b28313dd438f17a20ad913ad3201e831 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sedat=20=C3=96ZT=C3=9CRK?= <76204082+iamsedatozturk@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:59:47 +0300 Subject: [PATCH] Claude vite optimize ve check new version --- .../Seeds/LanguagesData.json | 24 + ui/public/version.json | 24 +- ui/src/components/template/Theme.tsx | 2 + ui/src/views/auth/Login.tsx | 19 - ui/src/views/version/ChangeLog.tsx | 20 + ui/src/views/version/swRegistration.ts | 788 +++++++++++------- ui/src/views/version/useAppVersionNotice.tsx | 77 ++ ui/src/vite-env.d.ts | 3 + ui/vite.config.ts | 275 +++--- 9 files changed, 837 insertions(+), 395 deletions(-) create mode 100644 ui/src/views/version/useAppVersionNotice.tsx diff --git a/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json b/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json index 9d94269e..25c7abed 100644 --- a/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json +++ b/api/src/Sozsoft.Platform.DbMigrator/Seeds/LanguagesData.json @@ -20586,6 +20586,30 @@ "en": "Update check failed.", "tr": "Güncelleme kontrolü başarısız" }, + { + "resourceName": "Platform", + "key": "App.UpdateCheckDisabled", + "en": "Update check is disabled", + "tr": "Güncelleme kontrolü kapalı" + }, + { + "resourceName": "Platform", + "key": "App.UpdateCheckDisabledInThisEnvironment", + "en": "The service worker is disabled in this environment, so the version cannot be checked.", + "tr": "Bu ortamda service worker devre dışı olduğu için sürüm kontrolü yapılamıyor." + }, + { + "resourceName": "Platform", + "key": "App.UpdateCompleted", + "en": "The application has been updated", + "tr": "Uygulama güncellendi" + }, + { + "resourceName": "Platform", + "key": "App.UpdatedToVersion", + "en": "New version", + "tr": "Yeni sürüm" + }, { "resourceName": "Platform", "key": "App.StaticLookup.AbpAccount", diff --git a/ui/public/version.json b/ui/public/version.json index 6c11781a..f2a3b933 100644 --- a/ui/public/version.json +++ b/ui/public/version.json @@ -1,6 +1,28 @@ { - "commit": "8146adf", + "commit": "81960df", "releases": [ + { + "version": "1.1.10", + "buildDate": "2026-07-31", + "commit": "2eef3ead35848341350659828a14dc4a463e57af", + "changeLog": [ + "- Kullanıcılara User Type sütunu eklendi. Hem \"İç Kullanıcılar\" hemde \"Dış Kullanıcılar\" tanımlanabiliyor.", + "- Lisans adedi sadece İç Kullanıcılar üzerinden çalışıyor.", + "- Scheduler ve TodoBard komponentler geliştirildi.", + "- Lookup editorlerin Static değerlere dil desteği eklendi." + ] + }, + { + "version": "1.1.09", + "buildDate": "2026-07-29", + "commit": "b0e78ac619dfebac2eb17edfc04551217ca4b93e", + "changeLog": [ + "- Wizard yetenekleri artırıldı. Menu Order eklendi.", + "- GanttView sample düzenlemeleri", + "- Schedulerview sample düzenlemeleri", + "- CryptographyXml version 10.0.10" + ] + }, { "version": "1.1.08", "buildDate": "2026-07-16", diff --git a/ui/src/components/template/Theme.tsx b/ui/src/components/template/Theme.tsx index 6fe2b748..2342e7d5 100644 --- a/ui/src/components/template/Theme.tsx +++ b/ui/src/components/template/Theme.tsx @@ -11,6 +11,7 @@ import { Helmet } from 'react-helmet' import { useNavigate, useLocation } from 'react-router-dom' import { getSetupStatus } from '@/services/setup.service' import { ROUTES_ENUM } from '@/routes/route.constant' +import { useAppVersionNotice } from '@/views/version/useAppVersionNotice' let didInit = false @@ -62,6 +63,7 @@ const Theme = (props: CommonProps) => { useDarkMode() useTabFocus() useNotification() + useAppVersionNotice() type ConfigProviderValue = NonNullable['value']> diff --git a/ui/src/views/auth/Login.tsx b/ui/src/views/auth/Login.tsx index 137072e8..2bb13905 100644 --- a/ui/src/views/auth/Login.tsx +++ b/ui/src/views/auth/Login.tsx @@ -47,9 +47,6 @@ const validationSchema = Yup.object().shape({ const Login = () => { const navigate = useNavigate() - const UiVersion = useStoreState((state) => state.locale.currentUiVersion) - const { setUiVersion } = useStoreActions((a) => a.locale) - const [message, setMessage] = useState('') const [error, setError] = useTimeOutMessage(300000) const [twoFactor, setTwoFactor] = useState(false) @@ -152,9 +149,6 @@ const Login = () => { setShowCaptcha(false) setError('') setMessage('') - - // Versiyon kontrolü - findUiVersion() } if (showCaptcha) { @@ -165,19 +159,6 @@ const Login = () => { setSubmitting(false) } - const findUiVersion = async () => { - try { - const res = await fetch(`/version.json?ts=${Date.now()}`) - const latest = (await res.json())?.releases?.[0]?.version - if (latest && UiVersion !== latest) { - setUiVersion(latest) - navigate(ROUTES_ENUM.protected.admin.changeLog) - } - } catch (e) { - console.warn('Versiyon okunamadı', e) - } - } - return ( <> { const { translate } = useLocalization() const [isCheckingUpdate, setIsCheckingUpdate] = useState(false) + // translate() eksik anahtarda anahtarın kendisini döndürüyor; kullanıcıya + // "::App.X" göstermemek için okunabilir bir karşılığa düş. + const label = (key: string, fallback: string) => { + const value = translate(key) + return !value || value === key || value.includes('::') ? fallback : value + } + const checkForUpdate = async () => { if (isCheckingUpdate) return setIsCheckingUpdate(true) @@ -34,6 +41,19 @@ export const useManualUpdateCheck = () => { , { placement: 'bottom-end' }, ) + } else if (result === 'disabled') { + toast.push( + + {label( + '::App.UpdateCheckDisabledInThisEnvironment', + 'Bu ortamda service worker devre dışı olduğu için sürüm kontrolü yapılamıyor.', + )} + , + { placement: 'bottom-end' }, + ) } else if (result === 'unsupported') { toast.push( diff --git a/ui/src/views/version/swRegistration.ts b/ui/src/views/version/swRegistration.ts index 39e75415..bc488359 100644 --- a/ui/src/views/version/swRegistration.ts +++ b/ui/src/views/version/swRegistration.ts @@ -1,122 +1,132 @@ import { registerSW } from 'virtual:pwa-register' import { store } from '@/store' -const ACTIVATION_RETRY_DELAY = 5_000 -const ACTIVATION_TIMEOUT = 20_000 +/** + * Service worker güncelleme akışı + * ------------------------------------------------------------------ + * 1. Kayıt + açılışta bir kez `update()` kontrolü. + * 2. Sekme görünürken periyodik, sekmeye dönüldüğünde ve internet geri + * geldiğinde tekrar kontrol -> yeni deploy açık uygulamada da yakalanır. + * 3. Yeni worker kurulmaya başlayınca (`installing`) engellemeyen bir bant + * ile "yeni sürüm indiriliyor" bilgisi gösterilir. + * 4. Kurulum bitince (`installed`) tam ekran overlay + sürüm notları gösterilir, + * SKIP_WAITING gönderilir ve `controllerchange` ile sayfa yenilenir. + * 5. Yenileme sonrası `consumeAppUpdatedVersion()` ile "güncellendi" bildirimi + * ve changelog yönlendirmesi yapılır. + */ -let registrationStarted = false -let activationInProgress = false -let reloadStarted = false -let currentRegistration: ServiceWorkerRegistration | undefined -let activateUpdateHandler: ((reloadPage?: boolean) => Promise) | undefined -let registrationInitialization: Promise | undefined -let activationRetryTimer: number | undefined -let activationTimeoutTimer: number | undefined -let overlayStoreUnsubscribe: (() => void) | undefined -const watchedInstallingWorkers = new WeakSet() +export type ManualUpdateResult = + | 'up-to-date' + | 'updating' + /** Tarayıcı service worker desteklemiyor. */ + | 'unsupported' + /** Destekleniyor ama bu ortamda kapalı (dev; VITE_PWA_DEV=true ile açılır). */ + | 'disabled' -export type ManualUpdateResult = 'up-to-date' | 'updating' | 'unsupported' +const PERIODIC_CHECK_INTERVAL = 15 * 60 * 1000 +const MIN_CHECK_GAP = 60 * 1000 +const ACTIVATION_RETRY_DELAY = 8_000 +const ACTIVATION_TIMEOUT = 30_000 +// `virtual:pwa-register` dev'de (devOptions kapalıyken) no-op'tur ve hiçbir +// callback çalışmaz. Kayıt promise'inin süresiz beklememesi için üst sınır. +const REGISTRATION_TIMEOUT = 10_000 +// `registration.update()` yavaş/kopuk ağda süresiz askıda kalabiliyor. +const UPDATE_CALL_TIMEOUT = 20_000 + +const withTimeout = (promise: Promise, ms: number) => + Promise.race([ + promise, + new Promise((resolve) => window.setTimeout(() => resolve(undefined), ms)), + ]) + +const UPDATED_VERSION_KEY = 'sozsoft:app-updated-version' +const CHUNK_RELOAD_GUARD_KEY = 'sozsoft:chunk-reload' type Release = { version: string changeLog?: string[] } -const getText = (value: unknown) => - value === undefined || value === null ? undefined : String(value) +let started = false +let registration: ServiceWorkerRegistration | undefined +let initialization: Promise | undefined +let activating = false +let reloading = false +let lastCheckAt = 0 +let periodicTimer: number | undefined +let activationRetryTimer: number | undefined +let activationTimeoutTimer: number | undefined +let storeUnsubscribe: (() => void) | undefined +let latestVersion: string | undefined +let releasePromise: Promise | undefined -function updateOverlayApiVersion(overlay: HTMLElement) { - const apiConfig = store.getState().abpConfig.config?.extraProperties - const apiEnvironment = getText(apiConfig?.environment) - const apiVersion = getText(apiConfig?.version) +const watchedRegistrations = new WeakSet() +const watchedWorkers = new WeakSet() - if (!apiEnvironment && !apiVersion) return false +/* ------------------------------------------------------------------ */ +/* Sürüm bilgisi */ +/* ------------------------------------------------------------------ */ - const apiValue = overlay.querySelector('[data-sw-api-version]') - if (apiValue) { - apiValue.textContent = `${apiEnvironment ?? '-'}:${apiVersion ?? '-'}` - } - - return true -} - -function watchOverlayApiVersion(overlay: HTMLElement) { - overlayStoreUnsubscribe?.() - - if (updateOverlayApiVersion(overlay)) return - - overlayStoreUnsubscribe = store.subscribe(() => { - if (!overlay.isConnected || updateOverlayApiVersion(overlay)) { - overlayStoreUnsubscribe?.() - overlayStoreUnsubscribe = undefined - } - }) -} - -async function loadUpdateOverlayDetails(overlay: HTMLElement) { - const state = store.getState() - const uiEnvironment = import.meta.env.MODE - - let uiVersion = state.locale.currentUiVersion - let latestRelease: Release | undefined +const loadLatestRelease = () => + (releasePromise ??= fetch(`/version.json?ts=${Date.now()}`, { cache: 'no-store' }) + .then((response) => { + if (!response.ok) throw new Error(`version.json isteği ${response.status} döndü`) + return response.json() as Promise<{ releases?: Release[] }> + }) + .then((data) => { + const release = data.releases?.[0] + latestVersion = release?.version ?? latestVersion + return release + }) + .catch((error: unknown) => { + console.warn('Sürüm bilgisi okunamadı.', error) + releasePromise = undefined + return undefined + })) +/** Yenileme sonrası bir kez okunur; yeni sürüm numarasını döner. */ +export const consumeAppUpdatedVersion = (): string | undefined => { try { - const response = await fetch(`/version.json?ts=${Date.now()}`, { cache: 'no-store' }) - if (!response.ok) throw new Error(`Version request failed with ${response.status}`) - - const releases = ((await response.json()) as { releases?: Release[] }).releases - latestRelease = releases?.[0] - uiVersion = latestRelease?.version ?? uiVersion - } catch (error) { - console.warn('Update details could not be loaded.', error) + const value = sessionStorage.getItem(UPDATED_VERSION_KEY) + if (value) sessionStorage.removeItem(UPDATED_VERSION_KEY) + return value ?? undefined + } catch { + return undefined } - - // The overlay may have been removed while version.json was loading. - if (!overlay.isConnected) return - - const uiValue = overlay.querySelector('[data-sw-ui-version]') - const releaseTitle = overlay.querySelector('[data-sw-release-title]') - const releaseList = overlay.querySelector('[data-sw-release-list]') - - if (uiValue) uiValue.textContent = `${uiEnvironment}:${uiVersion ?? '-'}` - watchOverlayApiVersion(overlay) - - if (!releaseList || !releaseTitle) return - - const changes = latestRelease?.changeLog?.filter(Boolean) ?? [] - releaseTitle.textContent = latestRelease?.version - ? `What's new in v${latestRelease.version}` - : "What's new" - releaseList.replaceChildren() - - if (changes.length === 0) { - const item = document.createElement('li') - item.className = 'sw-update-empty' - item.textContent = 'Release notes are not available.' - releaseList.appendChild(item) - return - } - - changes.forEach((change) => { - const item = document.createElement('li') - const icon = document.createElement('span') - const text = document.createElement('span') - icon.className = 'sw-update-check' - icon.setAttribute('aria-hidden', 'true') - icon.textContent = '\u2713' - text.textContent = change.replace(/^\s*-\s*/, '') - item.append(icon, text) - releaseList.appendChild(item) - }) } -function showUpdateOverlay() { - if (document.getElementById('sw-update-overlay')) return +/* ------------------------------------------------------------------ */ +/* Arayüz */ +/* ------------------------------------------------------------------ */ + +const STYLE_ID = 'sw-update-style' +const BANNER_ID = 'sw-update-banner' +const OVERLAY_ID = 'sw-update-overlay' + +function ensureStyles() { + if (document.getElementById(STYLE_ID)) return const style = document.createElement('style') - style.id = 'sw-update-overlay-style' + style.id = STYLE_ID style.textContent = ` - #sw-update-overlay { + #${BANNER_ID} { + position: fixed; + right: 16px; + bottom: 16px; + z-index: 99998; + display: flex; + align-items: center; + gap: 10px; + max-width: 320px; + padding: 12px 16px; + border-radius: 12px; + background: #111827; + color: #f9fafb; + font-size: 13px; + line-height: 1.4; + box-shadow: 0 10px 30px rgba(0,0,0,0.3); + } + #${OVERLAY_ID} { position: fixed; inset: 0; z-index: 99999; @@ -127,7 +137,7 @@ function showUpdateOverlay() { backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); } - #sw-update-overlay .sw-update-card { + .sw-update-card { display: flex; flex-direction: column; align-items: center; @@ -141,7 +151,8 @@ function showUpdateOverlay() { max-height: min(90vh, 720px); width: 90%; } - #sw-update-overlay .sw-update-spinner { + .sw-update-spinner { + flex: 0 0 auto; width: 56px; height: 56px; border: 5px solid #e5e7eb; @@ -149,21 +160,17 @@ function showUpdateOverlay() { border-radius: 50%; animation: sw-spin 0.8s linear infinite; } - @keyframes sw-spin { - to { transform: rotate(360deg); } + #${BANNER_ID} .sw-update-spinner { + width: 20px; + height: 20px; + border-width: 3px; + border-color: rgba(255,255,255,0.25); + border-top-color: #a5b4fc; } - #sw-update-overlay .sw-update-title { - font-size: 18px; - font-weight: 700; - color: #1f2937; - margin: 0; - } - #sw-update-overlay .sw-update-desc { - font-size: 14px; - color: #6b7280; - margin: 0; - } - #sw-update-overlay .sw-update-versions { + @keyframes sw-spin { to { transform: rotate(360deg); } } + .sw-update-title { font-size: 18px; font-weight: 700; color: #1f2937; margin: 0; } + .sw-update-desc { font-size: 14px; color: #6b7280; margin: 0; } + .sw-update-versions { display: flex; flex-wrap: wrap; align-items: center; @@ -175,13 +182,9 @@ function showUpdateOverlay() { background: #f3f4f6; color: #4b5563; font-size: 13px; - text-transform: capitalize; } - #sw-update-overlay .sw-update-separator { - color: #9ca3af; - margin: 0 4px; - } - #sw-update-overlay .sw-update-release { + .sw-update-separator { color: #9ca3af; margin: 0 4px; } + .sw-update-release { width: 100%; min-height: 0; overflow-y: auto; @@ -189,13 +192,8 @@ function showUpdateOverlay() { border-top: 1px solid #e5e7eb; padding-top: 14px; } - #sw-update-overlay .sw-update-release-title { - margin: 0 0 10px; - color: #374151; - font-size: 14px; - font-weight: 700; - } - #sw-update-overlay .sw-update-list { + .sw-update-release-title { margin: 0 0 10px; color: #374151; font-size: 14px; font-weight: 700; } + .sw-update-list { display: flex; flex-direction: column; gap: 8px; @@ -206,12 +204,8 @@ function showUpdateOverlay() { font-size: 13px; line-height: 1.45; } - #sw-update-overlay .sw-update-list li { - display: flex; - align-items: flex-start; - gap: 8px; - } - #sw-update-overlay .sw-update-check { + .sw-update-list li { display: flex; align-items: flex-start; gap: 8px; } + .sw-update-check { display: inline-flex; flex: 0 0 16px; align-items: center; @@ -225,51 +219,189 @@ function showUpdateOverlay() { font-size: 11px; font-weight: 700; } - #sw-update-overlay .sw-update-empty { - display: block; - color: #9ca3af; + .sw-update-empty { display: block; color: #9ca3af; } + .sw-update-action { + appearance: none; + border: 0; + cursor: pointer; + padding: 10px 20px; + border-radius: 8px; + background: #4f46e5; + color: #fff; + font-size: 14px; + font-weight: 600; } + .sw-update-action:hover { background: #4338ca; } @media (max-width: 640px) { - #sw-update-overlay .sw-update-card { - padding: 24px 20px; - } + .sw-update-card { padding: 24px 20px; } + #${BANNER_ID} { left: 16px; right: 16px; max-width: none; } } ` document.head.appendChild(style) +} + +function removeStyles() { + document.getElementById(STYLE_ID)?.remove() +} + +/** İndirme sürerken kullanıcıyı engellemeyen bilgilendirme. */ +function showDownloadBanner() { + if (document.getElementById(BANNER_ID) || document.getElementById(OVERLAY_ID)) return + ensureStyles() + + const banner = document.createElement('div') + banner.id = BANNER_ID + banner.setAttribute('role', 'status') + banner.setAttribute('aria-live', 'polite') + + const spinner = document.createElement('div') + spinner.className = 'sw-update-spinner' + + const text = document.createElement('span') + text.textContent = 'Yeni sürüm indiriliyor, lütfen sekmeyi kapatmayın...' + + banner.append(spinner, text) + document.body.appendChild(banner) +} + +function removeDownloadBanner() { + document.getElementById(BANNER_ID)?.remove() +} + +function getText(value: unknown) { + return value === undefined || value === null ? undefined : String(value) +} + +function applyApiVersion(overlay: HTMLElement) { + const apiConfig = store.getState().abpConfig.config?.extraProperties + const environment = getText(apiConfig?.environment) + const version = getText(apiConfig?.version) + if (!environment && !version) return false + + const target = overlay.querySelector('[data-sw-api-version]') + if (target) target.textContent = `${environment ?? '-'}:${version ?? '-'}` + return true +} + +function watchApiVersion(overlay: HTMLElement) { + storeUnsubscribe?.() + storeUnsubscribe = undefined + if (applyApiVersion(overlay)) return + + storeUnsubscribe = store.subscribe(() => { + if (!overlay.isConnected || applyApiVersion(overlay)) { + storeUnsubscribe?.() + storeUnsubscribe = undefined + } + }) +} + +async function fillOverlayDetails(overlay: HTMLElement) { + const release = await loadLatestRelease() + if (!overlay.isConnected) return + + const uiVersion = release?.version ?? store.getState().locale.currentUiVersion + const uiValue = overlay.querySelector('[data-sw-ui-version]') + if (uiValue) uiValue.textContent = `${import.meta.env.MODE}:${uiVersion ?? '-'}` + watchApiVersion(overlay) + + const releaseTitle = overlay.querySelector('[data-sw-release-title]') + const releaseList = overlay.querySelector('[data-sw-release-list]') + if (!releaseTitle || !releaseList) return + + releaseTitle.textContent = release?.version + ? `v${release.version} ile gelen yenilikler` + : 'Sürüm notları' + releaseList.replaceChildren() + + const changes = release?.changeLog?.filter(Boolean) ?? [] + if (changes.length === 0) { + const item = document.createElement('li') + item.className = 'sw-update-empty' + item.textContent = 'Bu sürüm için not bulunamadı.' + releaseList.appendChild(item) + return + } + + changes.forEach((change) => { + const item = document.createElement('li') + const icon = document.createElement('span') + const text = document.createElement('span') + icon.className = 'sw-update-check' + icon.setAttribute('aria-hidden', 'true') + icon.textContent = '✓' + text.textContent = change.replace(/^\s*-\s*/, '') + item.append(icon, text) + releaseList.appendChild(item) + }) +} + +function showActivationOverlay() { + removeDownloadBanner() + if (document.getElementById(OVERLAY_ID)) return + ensureStyles() const overlay = document.createElement('div') - overlay.id = 'sw-update-overlay' + overlay.id = OVERLAY_ID overlay.setAttribute('role', 'status') overlay.setAttribute('aria-live', 'polite') overlay.innerHTML = `
-

System Updating

-

Loading new version, please wait...
The page will reload automatically.

+

Sistem güncelleniyor

+

Yeni sürüm yükleniyor, lütfen bekleyin...
Sayfa otomatik olarak yenilenecek.

- UI: Loading... + UI: Yükleniyor... | - API: Loading... + API: Yükleniyor...
-

What's new

+

Sürüm notları

    -
  • Loading release notes...
  • +
  • Sürüm notları yükleniyor...
` document.body.appendChild(overlay) - void loadUpdateOverlayDetails(overlay) + void fillOverlayDetails(overlay) } -function hideUpdateOverlay() { - overlayStoreUnsubscribe?.() - overlayStoreUnsubscribe = undefined - document.getElementById('sw-update-overlay')?.remove() - document.getElementById('sw-update-overlay-style')?.remove() +/** Aktivasyon tamamlanamazsa kullanıcıyı belirsizlikte bırakma. */ +function showManualReloadPrompt() { + const overlay = document.getElementById(OVERLAY_ID) + if (!overlay) return + + overlay.querySelector('.sw-update-spinner')?.remove() + + const title = overlay.querySelector('[data-sw-title]') + const desc = overlay.querySelector('[data-sw-desc]') + if (title) title.textContent = 'Güncelleme tamamlanamadı' + if (desc) { + desc.textContent = 'Yeni sürüme geçmek için sayfayı yenileyin.' + } + + if (overlay.querySelector('.sw-update-action')) return + const button = document.createElement('button') + button.type = 'button' + button.className = 'sw-update-action' + button.textContent = 'Sayfayı yenile' + button.addEventListener('click', () => reloadForUpdate()) + overlay.querySelector('.sw-update-card')?.appendChild(button) } +function teardownUpdateUi() { + storeUnsubscribe?.() + storeUnsubscribe = undefined + removeDownloadBanner() + document.getElementById(OVERLAY_ID)?.remove() + removeStyles() +} + +/* ------------------------------------------------------------------ */ +/* Aktivasyon */ +/* ------------------------------------------------------------------ */ + function clearActivationTimers() { window.clearTimeout(activationRetryTimer) window.clearTimeout(activationTimeoutTimer) @@ -278,189 +410,283 @@ function clearActivationTimers() { } function reloadForUpdate() { - if (reloadStarted) return - reloadStarted = true + if (reloading) return + reloading = true clearActivationTimers() + try { + if (latestVersion) sessionStorage.setItem(UPDATED_VERSION_KEY, latestVersion) + } catch { + /* sessionStorage kullanılamıyorsa bildirim atlanır */ + } window.location.reload() } -function activateWaitingWorker( - registration: ServiceWorkerRegistration | undefined, - activateUpdate: (reloadPage?: boolean) => Promise, - showOverlay: boolean, -) { - if (activationInProgress) { - if (showOverlay) showUpdateOverlay() +function postSkipWaiting() { + registration?.waiting?.postMessage({ type: 'SKIP_WAITING' }) +} + +function activateWaitingWorker() { + if (activating) { + showActivationOverlay() return } - activationInProgress = true + activating = true + showActivationOverlay() + // Sürüm numarasını yenileme öncesinde hazır tut. + void loadLatestRelease() - if (showOverlay) { - showUpdateOverlay() - } + navigator.serviceWorker.addEventListener('controllerchange', reloadForUpdate, { once: true }) + postSkipWaiting() - // Workbox'ın controlling olayı herhangi bir nedenle kaçarsa native olay da - // yeni worker kontrolü aldığı anda sayfayı yeniler. - navigator.serviceWorker.addEventListener('controllerchange', reloadForUpdate, { - once: true, - }) + // Bazı tarayıcılarda ilk mesaj bekleyen worker'a ulaşmayabiliyor. + activationRetryTimer = window.setTimeout(postSkipWaiting, ACTIVATION_RETRY_DELAY) - // Workbox callback sırasına bağlı kalmadan waiting worker'ı doğrudan geçir. - registration?.waiting?.postMessage({ type: 'SKIP_WAITING' }) - - void activateUpdate(true).catch((error: unknown) => { - console.error('Service worker activation failed.', error) - activationInProgress = false - clearActivationTimers() - hideUpdateOverlay() - }) - - // Bazı tarayıcılarda Workbox mesajı bekleyen worker'a ulaşmayabiliyor. - // Kısa bir süre sonra native mesajla bir kez daha aktivasyon istenir. - activationRetryTimer = window.setTimeout(() => { - registration?.waiting?.postMessage({ type: 'SKIP_WAITING' }) - }, ACTIVATION_RETRY_DELAY) - - // Aktivasyon yine de tamamlanamazsa uygulamayı sonsuza kadar kilitleme. - // Eski worker çalışmaya devam eder; bir sonraki açılışta tekrar denenir. activationTimeoutTimer = window.setTimeout(() => { - activationInProgress = false clearActivationTimers() + activating = false - // Worker waiting durumundan çıktıysa aktivasyon gerçekleşmiş fakat - // controllerchange olayı kaçmış olabilir. Yeni precache'i almak için yenile. + // Worker waiting'den çıktıysa aktivasyon olmuş, sadece controllerchange + // kaçmış olabilir; yeni precache'i almak için yenile. if (!registration?.waiting && registration?.active) { reloadForUpdate() return } - hideUpdateOverlay() - console.warn('Service worker activation timed out; continuing with current version.') + console.warn('Service worker aktivasyonu zaman aşımına uğradı.') + showManualReloadPrompt() }, ACTIVATION_TIMEOUT) } -function showUpdateWhileInstalling(registration: ServiceWorkerRegistration) { - const installingWorker = registration.installing - if ( - !installingWorker || - !navigator.serviceWorker.controller || - watchedInstallingWorkers.has(installingWorker) - ) { - return - } +/* ------------------------------------------------------------------ */ +/* Güncelleme takibi */ +/* ------------------------------------------------------------------ */ - watchedInstallingWorkers.add(installingWorker) - showUpdateOverlay() +function trackInstallingWorker(reg: ServiceWorkerRegistration) { + const worker = reg.installing + // Controller yoksa bu ilk kurulumdur; kullanıcıya güncelleme demek yanlış olur. + if (!worker || !navigator.serviceWorker.controller || watchedWorkers.has(worker)) return - installingWorker.addEventListener('statechange', () => { - // Precache dosyalarından biri indirilemezse worker redundant olur. Eski - // uygulama çalışmaya devam eder ve kullanıcı kilitli ekranda bırakılmaz. - if (installingWorker.state === 'redundant' && !activationInProgress) { - hideUpdateOverlay() + watchedWorkers.add(worker) + showDownloadBanner() + + worker.addEventListener('statechange', () => { + if (worker.state === 'installed') { + activateWaitingWorker() + return + } + // Precache dosyalarından biri indirilemezse worker redundant olur; eski + // sürüm çalışmaya devam eder ve kullanıcı kilitli ekranda kalmaz. + if (worker.state === 'redundant' && !activating) { + console.warn('Yeni service worker kurulamadı, mevcut sürümle devam ediliyor.') + teardownUpdateUi() } }) } -function watchForUpdate(registration: ServiceWorkerRegistration) { - registration.addEventListener('updatefound', () => { - // Gerçek worker güncellemesi bulunduğu anda, precache indirmesi başlamadan - // Loading'i göster ve aktivasyon/reload tamamlanana kadar açık tut. - showUpdateWhileInstalling(registration) - }) - - // Listener bağlanmadan hemen önce başlamış bir kurulumu da kaçırma. - showUpdateWhileInstalling(registration) +function watchRegistration(reg: ServiceWorkerRegistration) { + if (watchedRegistrations.has(reg)) return + watchedRegistrations.add(reg) + reg.addEventListener('updatefound', () => trackInstallingWorker(reg)) + trackInstallingWorker(reg) } -function checkForUpdate(registration: ServiceWorkerRegistration) { - // Bir deploy versiyon numarası/tag değişmeden yapılsa bile sw.js içindeki - // precache manifest'i değişir. Uygulama açılırken yapılan bu tek kontrol - // yeni build'i algılar; açık uygulamada periyodik bir arka plan işi çalışmaz. - void registration.update().catch((error: unknown) => { - console.warn('Service worker update check failed.', error) +/** + * Kayıtlı worker'ın script'i artık sunulmuyorsa (ör. PWA kapatıldı, dosya adı + * değişti) sunucu SPA fallback'i yüzünden index.html döner ve tarayıcı + * "unsupported MIME type" SecurityError'ı fırlatır. Bu durumda eski kayıt + * kalıcı olarak bozuktur; temizlenmezse her kontrol aynı hatayı verir. + */ +async function unregisterIfStale(reg: ServiceWorkerRegistration, error: unknown) { + const isStaleScript = + error instanceof Error && (error.name === 'SecurityError' || /MIME type/i.test(error.message)) + if (!isStaleScript) return false + + console.warn('Geçersiz service worker kaydı temizleniyor.', error) + try { + await reg.unregister() + } catch { + /* temizlenemediyse bir sonraki açılışta tekrar denenir */ + } + if (registration === reg) registration = undefined + return true +} + +async function runUpdateCheck(force = false) { + if (!registration || activating || reloading) return + if (!force && !navigator.onLine) return + + const now = Date.now() + if (!force && now - lastCheckAt < MIN_CHECK_GAP) return + lastCheckAt = now + + const reg = registration + try { + await withTimeout(reg.update(), UPDATE_CALL_TIMEOUT) + } catch (error: unknown) { + if (!(await unregisterIfStale(reg, error))) { + console.warn('Service worker güncelleme kontrolü başarısız.', error) + } + } +} + +function startUpdateWatchers() { + window.clearInterval(periodicTimer) + periodicTimer = window.setInterval(() => { + if (document.visibilityState === 'visible') void runUpdateCheck() + }, PERIODIC_CHECK_INTERVAL) + + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') void runUpdateCheck() }) + window.addEventListener('online', () => void runUpdateCheck()) +} + +/** + * Deploy sonrası sunucudan kaldırılmış bir lazy chunk istenirse Vite + * `vite:preloadError` fırlatır. Beyaz ekran yerine bir kez sayfayı yenile. + */ +function handleChunkLoadFailures() { + window.addEventListener('vite:preloadError', (event) => { + if (reloading) return + try { + if (sessionStorage.getItem(CHUNK_RELOAD_GUARD_KEY)) return + sessionStorage.setItem(CHUNK_RELOAD_GUARD_KEY, '1') + } catch { + return + } + event.preventDefault() + console.warn('Eksik chunk tespit edildi, sayfa yenileniyor.') + reloading = true + window.location.reload() + }) + + // Sayfa sorunsuz açıldıysa koruma bayrağını temizle. + window.setTimeout(() => { + try { + sessionStorage.removeItem(CHUNK_RELOAD_GUARD_KEY) + } catch { + /* yoksay */ + } + }, 10_000) +} + +/* ------------------------------------------------------------------ */ +/* Genel API */ +/* ------------------------------------------------------------------ */ + +/** Service worker yalnızca prod'da, dev'de ise VITE_PWA_DEV=true ile aktiftir. */ +const isServiceWorkerEnabled = import.meta.env.PROD || import.meta.env.VITE_PWA_DEV === 'true' + +/** Kapalıyken önceki oturumlardan kalan kayıtları temizler. */ +async function unregisterAll() { + try { + const registrations = await navigator.serviceWorker.getRegistrations() + await Promise.all(registrations.map((reg) => reg.unregister().catch(() => false))) + } catch { + /* yoksay */ + } } export const registerServiceWorker = () => { - if (registrationStarted || !('serviceWorker' in navigator)) return - registrationStarted = true + if (started || !('serviceWorker' in navigator)) return + started = true - registrationInitialization = navigator.serviceWorker - .getRegistration() - .catch(() => undefined) - .then((existingRegistration) => { - if (existingRegistration) { - watchForUpdate(existingRegistration) - } + handleChunkLoadFailures() - const activateUpdate = registerSW({ - immediate: true, - onRegisteredSW(_swUrl, registration) { - if (!registration) return + if (!isServiceWorkerEnabled) { + // Dev'de SW kapalı. Daha önce kaydedilmiş dev worker'ı bırakırsak script'i + // (/dev-sw.js) artık üretilmediği için sunucu index.html döner ve her + // güncelleme kontrolü "unsupported MIME type" hatası verir. + initialization = unregisterAll() + return + } - if (registration !== existingRegistration) { - watchForUpdate(registration) + initialization = new Promise((resolve) => { + // Kayıt callback'i hiç çalışmazsa bile promise'i serbest bırak; aksi halde + // `checkForAppUpdate()` "Kontrol ediliyor..." durumunda takılı kalır. + const registrationTimeout = window.setTimeout(resolve, REGISTRATION_TIMEOUT) + const settle = () => { + window.clearTimeout(registrationTimeout) + resolve() + } + + registerSW({ + immediate: true, + onRegisteredSW(_swUrl, reg) { + if (reg) { + registration = reg + watchRegistration(reg) + startUpdateWatchers() + + // Kurulumu tamamlanmış, aktif olandan farklı bir worker bekliyorsa + // doğrudan devreye al. + if (reg.waiting && navigator.serviceWorker.controller) { + activateWaitingWorker() + } else { + void runUpdateCheck(true) } - currentRegistration = registration - checkForUpdate(registration) - - // waiting yalnızca aktif worker'dan farklı, kurulumu tamamlanmış gerçek - // bir worker olduğunda bulunur. Onu doğrudan etkinleştir. - if (registration.waiting && navigator.serviceWorker.controller) { - activateWaitingWorker(registration, activateUpdate, true) - } - }, - onNeedRefresh() { - // Bu callback worker'ın bütün yeni JS/CSS dosyalarını başarıyla precache'e - // almasından sonra çalışır; aktivasyon ve reload atomik yapılır. - activateWaitingWorker(currentRegistration, activateUpdate, true) - }, - onNeedReload() { - reloadForUpdate() - }, - onOfflineReady() { - console.log('📦 App offline ready') - }, - onRegisterError(error) { - activationInProgress = false - clearActivationTimers() - hideUpdateOverlay() - console.error('Service worker registration failed.', error) - }, - }) - activateUpdateHandler = activateUpdate + } + settle() + }, + onNeedRefresh() { + // Yeni worker tüm kabuk dosyalarını precache'e aldıktan sonra çalışır. + activateWaitingWorker() + }, + onRegisterError(error) { + activating = false + clearActivationTimers() + teardownUpdateUi() + console.error('Service worker kaydı başarısız.', error) + settle() + }, }) + }) } export const checkForAppUpdate = async (): Promise => { if (!('serviceWorker' in navigator)) return 'unsupported' + if (!isServiceWorkerEnabled) return 'disabled' - // App içindeki normal kayıt işlemi henüz bitmediyse önce onu tamamla. - await registrationInitialization - const registration = - currentRegistration ?? (await navigator.serviceWorker.ready.catch(() => undefined)) + await initialization + // `navigator.serviceWorker.ready` kayıt yokken (ör. dev ortamı) hiçbir zaman + // resolve etmez; getRegistration() kayıt yoksa undefined döner. + const reg = + registration ?? (await navigator.serviceWorker.getRegistration().catch(() => undefined)) + if (!reg) return 'unsupported' - if (!registration) return 'unsupported' + registration ??= reg + watchRegistration(reg) + + if (reg.waiting && navigator.serviceWorker.controller) { + activateWaitingWorker() + return 'updating' + } let updateFound = false const handleUpdateFound = () => { updateFound = true } - registration.addEventListener('updatefound', handleUpdateFound) + + let staleRegistrationCleared = false + reg.addEventListener('updatefound', handleUpdateFound) try { - await registration.update() + await withTimeout(reg.update(), UPDATE_CALL_TIMEOUT) + } catch (error: unknown) { + staleRegistrationCleared = await unregisterIfStale(reg, error) + if (!staleRegistrationCleared) { + console.warn('Manuel güncelleme kontrolü başarısız.', error) + } } finally { - registration.removeEventListener('updatefound', handleUpdateFound) + reg.removeEventListener('updatefound', handleUpdateFound) + lastCheckAt = Date.now() } - const updateAvailable = Boolean(updateFound || registration.installing || registration.waiting) - if (!updateAvailable) return 'up-to-date' + // Bozuk kayıt temizlendi; bir sonraki açılışta yeni worker kaydedilecek. + if (staleRegistrationCleared) return 'up-to-date' - if (registration.waiting && activateUpdateHandler) { - activateWaitingWorker(registration, activateUpdateHandler, true) - } + if (!updateFound && !reg.installing && !reg.waiting) return 'up-to-date' - // installing durumundaysa gerçek onNeedRefresh callback'i indirme bittikten - // sonra aynı overlay/aktivasyon akışını başlatır. + if (reg.waiting && navigator.serviceWorker.controller) activateWaitingWorker() + // `installing` durumundaysa statechange dinleyicisi aynı akışı sürdürür. return 'updating' } diff --git a/ui/src/views/version/useAppVersionNotice.tsx b/ui/src/views/version/useAppVersionNotice.tsx new file mode 100644 index 00000000..fd41620c --- /dev/null +++ b/ui/src/views/version/useAppVersionNotice.tsx @@ -0,0 +1,77 @@ +import { useEffect, useRef } from 'react' +import { useNavigate } from 'react-router-dom' +import { ROUTES_ENUM } from '@/routes/route.constant' +import { useStoreActions } from '@/store' +import { useLocalization } from '@/utils/hooks/useLocalization' +import { consumeAppUpdatedVersion } from './swRegistration' + +/** + * Açılışta çalışan tek sürüm kaynağı: + * - `version.json`'daki güncel sürümü store'a yazar (footer/changelog bunu okur). + * - Service worker güncellemesi sonrası yenilenen sayfada "güncellendi" + * bildirimini gösterip changelog'a yönlendirme sunar. + * + * Daha önce bu işi Login ekranı yapıyordu; store'daki başlangıç sürümü + * `undefined` olduğu için her girişte changelog'a zorla yönlendiriyordu. + */ +export const useAppVersionNotice = () => { + const navigate = useNavigate() + const { translate } = useLocalization() + const setUiVersion = useStoreActions((actions) => actions.locale.setUiVersion) + const handled = useRef(false) + + useEffect(() => { + if (handled.current) return + handled.current = true + + const label = (key: string, fallback: string) => { + const value = translate(key) + return !value || value === key || value.includes('::') ? fallback : value + } + + const updatedVersion = consumeAppUpdatedVersion() + + const controller = new AbortController() + fetch(`/version.json?ts=${Date.now()}`, { cache: 'no-store', signal: controller.signal }) + .then((response) => (response.ok ? response.json() : undefined)) + .then(async (data?: { releases?: { version: string }[] }) => { + const latest = data?.releases?.[0]?.version + if (latest) setUiVersion(latest) + + if (!updatedVersion) return + + // toast/Notification (ve bağımlısı framer-motion) yalnızca güncelleme + // sonrası bu tek seferlik bildirimde gerekiyor; entry chunk'ta taşınmaz. + const [{ default: toast }, { default: Notification }] = await Promise.all([ + import('@/components/ui/toast'), + import('@/components/ui/Notification'), + ]) + + toast.push( + + {label('::App.UpdatedToVersion', 'Yeni sürüm')} v{updatedVersion} —{' '} + + , + { placement: 'bottom-end' }, + ) + }) + .catch(() => { + /* sürüm bilgisi okunamadıysa sessiz geç */ + }) + + return () => controller.abort() + }, []) +} + +export default useAppVersionNotice diff --git a/ui/src/vite-env.d.ts b/ui/src/vite-env.d.ts index 059f644e..a33485bc 100644 --- a/ui/src/vite-env.d.ts +++ b/ui/src/vite-env.d.ts @@ -7,6 +7,9 @@ interface ImportMetaEnv { readonly VITE_REACT_APP_VERSION: string readonly VITE_AI_URL: string readonly VITE_GOOGLE_MAPS_API_KEY: string + readonly VITE_USE_POLLING?: string + /** Dev sunucusunda service worker'ı açar (varsayılan kapalı). */ + readonly VITE_PWA_DEV?: string } interface ImportMeta { diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 3fdaeb04..976331a1 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -2,16 +2,47 @@ import { defineConfig, loadEnv } from 'vite' import react from '@vitejs/plugin-react' import path from 'path' import { VitePWA } from 'vite-plugin-pwa' -// import { fetchTenantNames } from './src/services/tenants' -export default defineConfig(async ({ mode }) => { +/** + * Precache stratejisi + * ------------------------------------------------------------------ + * `dist` klasörü ~55 MB (48 adet DevExtreme teması ~33 MB + lazy chunk'lar + * ~20 MB). Bunların tamamını precache etmek her deploy'da service worker'ın + * on-larca MB indirmesine, mobil/yavaş bağlantıda kurulumun timeout'a düşüp + * worker'ın `redundant` olmasına ve güncellemenin hiç tamamlanmamasına yol + * açıyordu. + * + * Bu yüzden precache SADECE uygulama kabuğudur (index.html + entry JS + CSS, + * ~2.8 MB). Geri kalan her şey (lazy chunk, tema CSS'i, görsel, font) runtime + * cache ile ilk kullanımda saklanır. Dosya adları hash'li olduğu için + * CacheFirst güvenlidir; hash'siz public dosyalarda StaleWhileRevalidate + * kullanılır. + */ +const PRECACHE_GLOBS = [ + 'index.html', + // manifest.webmanifest ve manifest ikonları vite-plugin-pwa tarafından + // otomatik eklenir; buraya yazmak workbox'ta "conflicting entries" hatası verir. + 'favicon.ico', + 'assets/js/entry-*.js', + // Sadece entry CSS'i. Rapor tasarımcısı/görüntüleyici CSS'leri (~580 KB) + // lazy yüklendiği için runtime cache'e bırakılır. + 'assets/css/index-*.css', +] + +// Deploy'da silinen eski build dosyalarını precache manifestine sokmamak için +// runtime cache'e alınacak hash'li uygulama varlıkları. +const HASHED_ASSET_PATTERN = /\/assets\/.+\.(?:js|css|wasm)$/ + +export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), '') - const apiUrl = env.VITE_API_URL - const baseDomains = ['sozsoft.com', 'dev.sozsoft.com', '.sozsoft.com'] - const tenantHosts: string[] = [] + const isProduction = mode === 'production' + const usePolling = env.VITE_USE_POLLING === 'true' + // Dev'de service worker varsayılan olarak kapalıdır; açık olduğunda HMR + // sonrası eski chunk'lar cache'den servis edilip kafa karıştırıyor. + const enableDevSw = env.VITE_PWA_DEV === 'true' - const allowedHosts = Array.from(new Set(['localhost', ...baseDomains, ...tenantHosts])) - console.log('> [vite] allowedHosts:', allowedHosts) + const baseDomains = ['sozsoft.com', 'dev.sozsoft.com', '.sozsoft.com'] + const allowedHosts = Array.from(new Set(['localhost', ...baseDomains])) return { plugins: [ @@ -20,87 +51,83 @@ export default defineConfig(async ({ mode }) => { // Yeni sürüm önce tamamen indirilir, ardından uygulama kontrollü olarak // yeni service worker'a geçer. Böylece eski ekran ile yeni chunk'lar karışmaz. registerType: 'prompt', - // Kayıt ve loading akışı src/views/version/swRegistration.ts tarafından yönetilir. + // Kayıt ve güncelleme akışı src/views/version/swRegistration.ts tarafından yönetilir. injectRegister: false, - // Dev ortamında SW'yi aç, prod'da kapalı tut (build edilmiş SW prod'da zaten aktif olur) devOptions: { - enabled: mode !== 'production', - type: 'module', // Modern module worker kullan + enabled: enableDevSw, + type: 'module', }, - workbox: - mode === 'production' - ? { - globDirectory: 'dist', - // Eski bir sekme yeni deploy sırasında henüz açmadığı lazy - // chunk'ları da çalıştırabilsin; tüm uygulama dosyalarını sakla. - globPatterns: ['**/*.{js,css,html,wasm}'], + workbox: { + globDirectory: 'dist', + globPatterns: PRECACHE_GLOBS, - // Büyük asset'leri de cache'leyebil - maximumFileSizeToCacheInBytes: 15 * 1024 * 1024, + // Kabuk dosyaları büyük olabilir (entry CSS ~1.3 MB). + maximumFileSizeToCacheInBytes: 4 * 1024 * 1024, - // Yeni worker tüm precache dosyalarını indirdikten sonra uygulama - // SKIP_WAITING mesajını gönderir. clientsClaim açık sekmeleri o anda yeniler. - clientsClaim: true, - skipWaiting: false, + // Yeni worker precache'i bitirdikten sonra uygulama SKIP_WAITING + // gönderir; clientsClaim açık sekmelerin kontrolünü o an devralır. + clientsClaim: true, + skipWaiting: false, + cleanupOutdatedCaches: true, - // Eski workbox cache'lerini temizle - cleanupOutdatedCaches: true, + navigateFallback: '/index.html', + navigateFallbackAllowlist: [/^(?!\/__).*/], + navigateFallbackDenylist: [/^\/api\//, /^\/connect\//, /^\/swagger/, /\/version\.json$/], - // SPA fallback'i API çağrılarına uygulama - navigateFallbackDenylist: [/^\/api\//], - - runtimeCaching: [ - { - urlPattern: /\.(?:js|css|wasm)$/, - // Hash'li build dosyaları immutable'dır. Normal kullanımda ağ - // beklenmez; deploy güncellemesini precache manifest'i yönetir. - handler: 'CacheFirst', - options: { - cacheName: 'static-resources-v2', - expiration: { - maxEntries: 200, - maxAgeSeconds: 30 * 24 * 60 * 60, - }, - cacheableResponse: { - statuses: [0, 200], - }, - }, - }, - { - // Changelog/versiyon bilgisi cache yüzünden eski kalmamalı. - urlPattern: /\/version\.json$/, - handler: 'NetworkFirst', - options: { - cacheName: 'app-version-v1', - networkTimeoutSeconds: 3, - expiration: { - maxEntries: 5, - maxAgeSeconds: 24 * 60 * 60, - }, - cacheableResponse: { - statuses: [0, 200], - }, - }, - }, - { - urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp|ico)$/, - handler: 'CacheFirst', - options: { - cacheName: 'images', - expiration: { - maxEntries: 100, - maxAgeSeconds: 30 * 24 * 60 * 60, // 30 gün - }, - }, - }, - ], - - // ⭐ YENİ EKLENEN: Additional navigation route for SPA - navigateFallback: '/index.html', - navigateFallbackAllowlist: [/^(?!\/__).*/], - } - : undefined, + runtimeCaching: [ + { + // Sürüm/changelog bilgisi asla eski kalmamalı. + urlPattern: /\/version\.json$/, + handler: 'NetworkFirst', + options: { + cacheName: 'app-version-v2', + networkTimeoutSeconds: 3, + expiration: { maxEntries: 2, maxAgeSeconds: 24 * 60 * 60 }, + cacheableResponse: { statuses: [0, 200] }, + }, + }, + { + // Hash'li build çıktıları immutable'dır. + urlPattern: HASHED_ASSET_PATTERN, + handler: 'CacheFirst', + options: { + cacheName: 'app-assets-v3', + expiration: { maxEntries: 400, maxAgeSeconds: 60 * 24 * 60 * 60 }, + cacheableResponse: { statuses: [0, 200] }, + }, + }, + { + // public/css altındaki DevExtreme temaları hash'siz; içerik + // sürümle değişebildiği için arka planda tazelenir. + urlPattern: /\/css\/[\w.-]+\.css$/, + handler: 'StaleWhileRevalidate', + options: { + cacheName: 'dx-themes-v1', + expiration: { maxEntries: 12, maxAgeSeconds: 30 * 24 * 60 * 60 }, + cacheableResponse: { statuses: [0, 200] }, + }, + }, + { + urlPattern: /\.(?:woff2?|ttf|otf|eot)$/, + handler: 'CacheFirst', + options: { + cacheName: 'fonts-v1', + expiration: { maxEntries: 40, maxAgeSeconds: 180 * 24 * 60 * 60 }, + cacheableResponse: { statuses: [0, 200] }, + }, + }, + { + urlPattern: /\.(?:png|jpe?g|svg|gif|webp|ico)$/, + handler: 'CacheFirst', + options: { + cacheName: 'images-v2', + expiration: { maxEntries: 150, maxAgeSeconds: 30 * 24 * 60 * 60 }, + cacheableResponse: { statuses: [0, 200] }, + }, + }, + ], + }, manifest: { name: 'Sözsoft Platform', @@ -137,10 +164,14 @@ export default defineConfig(async ({ mode }) => { server: { open: true, port: 3000, - // ⭐ YENİ EKLENEN: Hot reload için polling watch: { - usePolling: env.VITE_USE_POLLING === 'true', - interval: env.VITE_USE_POLLING === 'true' ? 1000 : undefined, + usePolling, + interval: usePolling ? 1000 : undefined, + }, + // Sık dokunulan giriş dosyalarını önceden transform ederek ilk + // gezinmedeki dev sunucu gecikmesini azalt. + warmup: { + clientFiles: ['./src/main.tsx', './src/App.tsx', './src/components/layouts/index.ts'], }, }, @@ -154,24 +185,85 @@ export default defineConfig(async ({ mode }) => { }, }, + optimizeDeps: { + // Barrel/CJS ağırlıklı paketleri dev cold-start'ta tek seferde + // prebundle et; aksi halde ilk sayfa yüzlerce ayrı istek atıyor. + include: [ + 'react', + 'react-dom', + 'react-dom/client', + 'react-router-dom', + '@tanstack/react-query', + '@tanstack/react-table', + 'axios', + 'dayjs', + 'formik', + 'yup', + 'classnames', + 'lodash', + 'easy-peasy', + 'framer-motion', + 'react-icons/fa', + // CJS/UMD bundle. `exclude` edilirse interop bozulup + // "Babel.transform is not a function" hatası veriyor. Ayrıca burada + // listelenince dinamik import anındaki "new dependency optimized" + // kaynaklı sayfa yenilemesi de olmuyor. + '@babel/standalone', + ], + }, + + esbuild: { + legalComments: 'none', + drop: isProduction ? ['debugger'] : [], + }, + build: { outDir: 'dist', + // Vite'ın varsayılan 'baseline-widely-available' hedefi bilerek korunuyor: + // es2022'ye sabitlemek esbuild'i downlevel'a zorlayıp entry chunk'ı + // ~200 KB büyütüyor (ölçüldü). chunkSizeWarningLimit: 2000, sourcemap: false, emptyOutDir: true, + cssCodeSplit: true, + // Modern tarayıcı hedefinde polyfill gereksiz; entry'den birkaç KB düşer. + modulePreload: { polyfill: false }, + // Build sonunda gzip boyutu hesaplamak bu boyutta bir çıktıda dakikalar sürüyor. + reportCompressedSize: false, rollupOptions: { output: { - entryFileNames: `assets/js/[name]-[hash].js`, - chunkFileNames: `assets/js/[name]-[hash].js`, - assetFileNames: ({ name }) => { - if (/\.(css)$/.test(name ?? '')) { - return 'assets/css/[name]-[hash][extname]' - } + // `entry-` öneki precache glob'unun sadece gerçek entry chunk'ı + // yakalamasını sağlar (node_modules kaynaklı `index-*.js` chunk'ları + // yanlışlıkla precache'e girmesin). + entryFileNames: 'assets/js/entry-[name]-[hash].js', + chunkFileNames: 'assets/js/[name]-[hash].js', + assetFileNames: ({ name }: { name?: string }) => { + if (/\.css$/.test(name ?? '')) return 'assets/css/[name]-[hash][extname]' if (/\.(png|jpe?g|svg|gif|ico|webp)$/.test(name ?? '')) { return 'assets/img/[name]-[hash][extname]' } + if (/\.(woff2?|ttf|otf|eot)$/.test(name ?? '')) { + return 'assets/fonts/[name]-[hash][extname]' + } return 'assets/[name]-[hash][extname]' }, + // SADECE her açılışta zaten yüklenen (%100 eager) çekirdek vendor'lar + // ayrılır; bunlar sürümler arası değişmediği için tarayıcı cache'inde + // kalır ve deploy sonrası yeniden indirilmez. + // + // Not: framer-motion / lodash / formik gibi paketleri de buraya eklemek + // ölçüldü ve zararlı çıktı — Rollup'ın otomatik chunk'lamasında bu + // paketlerin bir kısmı yalnızca lazy view'lara giriyor; tek chunk'a + // toplandıklarında ilk yükleme ~650 KB büyüyor. + manualChunks(id) { + if (!id.includes('node_modules')) return undefined + const file = id.replace(/\\/g, '/') + if (/\/node_modules\/(react|react-dom|scheduler)\//.test(file)) return 'vendor-react' + if (/\/node_modules\/(react-router|react-router-dom|@remix-run)\//.test(file)) { + return 'vendor-router' + } + return undefined + }, }, }, }, @@ -180,16 +272,11 @@ export default defineConfig(async ({ mode }) => { host: '0.0.0.0', port: 80, open: false, - // Hesaplanan allowedHosts'u kullan allowedHosts, }, define: { 'process.env': {}, - // ⭐ YENİ EKLENEN: Version tracking için global değişkenler - __APP_VERSION__: JSON.stringify(process.env.VITE_APP_VERSION || '1.0.0'), - __BUILD_DATE__: JSON.stringify(new Date().toISOString()), - __APP_MODE__: JSON.stringify(mode), }, } })