Reduce pwa consumption

pull/2230/head
Jérémy Rialland 2022-08-03 17:00:50 +02:00 committed by Jérémy Rialland
parent 35046b400b
commit e17c84882f
16 changed files with 260 additions and 115 deletions

4
site/.gitignore vendored
View File

@ -1,6 +1,6 @@
.env
source/data/*
!source/data/versement-mobilité.json
source/public/data/*
!source/public/data/versement-mobilité.json
cypress/videos
cypress/screenshots
cypress/downloads

View File

@ -7,6 +7,7 @@ const dataDir = join(
'..',
'..',
'source',
'public',
'data'
)

View File

@ -15,8 +15,10 @@ import {
configSituationSelector,
situationSelector,
} from '@/selectors/simulationSelectors'
import { ErrorBoundary } from '@sentry/react'
import { FallbackRender } from '@sentry/react/types/errorboundary'
import rules from 'modele-social'
import { StrictMode, useContext, useMemo } from 'react'
import { ComponentProps, StrictMode, useContext, useMemo } from 'react'
import { Helmet } from 'react-helmet-async'
import { useTranslation } from 'react-i18next'
import { useSelector } from 'react-redux'
@ -34,6 +36,7 @@ import Iframes from './pages/Iframes'
import Integration from './pages/integration/index'
import Landing from './pages/Landing/Landing'
import Nouveautés from './pages/Nouveautes/Nouveautes'
import Offline from './pages/Offline'
import Simulateurs from './pages/Simulateurs'
import Stats from './pages/Stats/LazyStats'
import Provider, { ProviderProps } from './Provider'
@ -106,6 +109,14 @@ const Router = () => {
)
}
const CatchOffline = ({ error }: ComponentProps<FallbackRender>) => {
if (error.message.includes('Failed to fetch dynamically imported module')) {
return <Offline />
} else {
throw error
}
}
const App = () => {
const { t } = useTranslation()
const sitePaths = useContext(SitePathsContext)
@ -119,32 +130,35 @@ const App = () => {
/>
<Container>
{/* Passing location down to prevent update blocking */}
<Switch>
<Route path={sitePaths.créer.index} component={Créer} />
<Route path={sitePaths.gérer.index} component={Gérer} />
<Route path={sitePaths.simulateurs.index} component={Simulateurs} />
<Route
path={sitePaths.documentation.index}
component={Documentation}
/>
<Route path={sitePaths.développeur.index} component={Integration} />
<Route path={sitePaths.nouveautés} component={Nouveautés} />
<CompatRoute path={sitePaths.stats} component={Stats} />
<Route path={sitePaths.budget} component={Budget} />
<Route path={sitePaths.accessibilité} component={Accessibilité} />
<ErrorBoundary fallback={CatchOffline}>
{/* Passing location down to prevent update blocking */}
<Switch>
<Route path={sitePaths.créer.index} component={Créer} />
<Route path={sitePaths.gérer.index} component={Gérer} />
<Route path={sitePaths.simulateurs.index} component={Simulateurs} />
<Route
path={sitePaths.documentation.index}
component={Documentation}
/>
<Route path={sitePaths.développeur.index} component={Integration} />
<Route path={sitePaths.nouveautés} component={Nouveautés} />
<CompatRoute path={sitePaths.stats} component={Stats} />
<Route path={sitePaths.budget} component={Budget} />
<Route path={sitePaths.accessibilité} component={Accessibilité} />
<Route
exact
path="/dev/integration-test"
component={IntegrationTest}
/>
<Route exact path="/dev/personas" component={Personas} />
<Route
exact
path="/dev/integration-test"
component={IntegrationTest}
/>
<Route exact path="/dev/personas" component={Personas} />
<Route component={Route404} />
</Switch>
<Spacing xxl />
<Route component={Route404} />
</Switch>
<Spacing xxl />
</ErrorBoundary>
</Container>
{!isEmbedded && <Footer />}
</StyledLayout>
)

View File

@ -96,7 +96,15 @@ async function tauxVersementTransport(
codeCommune = '132' + commune.codePostal.slice(-2)
}
// 2. On récupère le versement transport associé
const json = (await import('@/data/versement-mobilité.json')).default
const response = await fetch('/data/versement-mobilité.json')
if (!response.ok) {
// eslint-disable-next-line no-console
console.error(response)
return 0
}
const json =
(await response.json()) as typeof import('@/public/data/versement-mobilité.json')
return json[codeCommune as keyof typeof json] ?? 0
}

View File

@ -1,23 +1,32 @@
import { Appear } from '@/components/ui/animate'
import Emoji from '@/components/utils/Emoji'
import { SitePathsContext } from '@/components/utils/SitePathsContext'
import lastRelease from '@/data/last-release.json'
import { Banner, HideButton, InnerBanner } from '@/design-system/banner'
import { Link } from '@/design-system/typography/link'
import { useFetchData } from '@/hooks/useFetchData'
import { useContext, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { getItem, setItem } from '../../storage/safeLocalStorage'
const localStorageKey = 'last-viewed-release'
export const hideNewsBanner = () => {
setItem(localStorageKey, lastRelease.name)
type LastRelease = typeof import('@/public/data/last-release.json')
export const useHideNewsBanner = () => {
const { data: lastReleaseData } = useFetchData<LastRelease>(
'/data/last-release.json'
)
useEffect(() => {
if (lastReleaseData) {
setItem(localStorageKey, lastReleaseData.name)
}
}, [lastReleaseData])
}
export const determinant = (word: string) =>
/^[aeiouy]/i.exec(word) ? 'd' : 'de '
export default function NewsBanner() {
function NewsBanner({ lastRelease }: { lastRelease: LastRelease }) {
const sitePaths = useContext(SitePathsContext)
const { t } = useTranslation()
const lastViewedRelease = getItem(localStorageKey)
@ -58,3 +67,13 @@ export default function NewsBanner() {
</Banner>
)
}
export default function NewsBannerWrapper() {
const { data: lastReleaseData } = useFetchData<LastRelease>(
'/data/last-release.json'
)
return lastReleaseData === null ? null : (
<NewsBanner lastRelease={lastReleaseData} />
)
}

View File

@ -0,0 +1,37 @@
import { useEffect, useState } from 'react'
export const useFetchData = <T>(url: string) => {
const [data, setData] = useState<null | T>(null)
const [loading, setLoading] = useState<boolean>(true)
useEffect(() => {
const controller = new AbortController()
const fetchData = async () => {
setLoading(true)
try {
const response = await fetch(url, {
signal: controller.signal,
})
if (response.ok) {
const resData = (await response.json()) as T
setData(resData)
}
setLoading(false)
} catch (err) {
if (err instanceof Error && err.name !== 'AbortError') {
throw err
}
}
}
void fetchData()
return () => {
controller.abort()
}
}, [url])
return { data, loading }
}

View File

@ -1,4 +1,4 @@
import { determinant, hideNewsBanner } from '@/components/layout/NewsBanner'
import { determinant, useHideNewsBanner } from '@/components/layout/NewsBanner'
import MoreInfosOnUs from '@/components/MoreInfosOnUs'
import Emoji from '@/components/utils/Emoji'
import { MarkdownWithAnchorLinks } from '@/components/utils/markdown'
@ -10,7 +10,8 @@ import { Container, Grid } from '@/design-system/layout'
import { H1 } from '@/design-system/typography/heading'
import { GenericButtonOrLinkProps, Link } from '@/design-system/typography/link'
import { Body } from '@/design-system/typography/paragraphs'
import { useContext, useEffect, useMemo, useState } from 'react'
import { useFetchData } from '@/hooks/useFetchData'
import { useContext, useMemo } from 'react'
import { Navigate, useMatch, useNavigate } from 'react-router-dom-v5-compat'
import styled from 'styled-components'
import { TrackPage } from '../../ATInternetTracking'
@ -22,31 +23,21 @@ type ReleasesData = Array<{
description: string
}>
type Releases = typeof import('@/public/data/releases.json')
export default function Nouveautés() {
// The release.json file may be big, we don't want to include it in the main
// bundle, that's why we only fetch it on this page.
const [data, setData] = useState<ReleasesData>([])
useEffect(() => {
import('@/data/releases.json')
.then(({ default: data }) => {
setData(data)
})
.catch((err) =>
// eslint-disable-next-line no-console
console.error(err)
)
}, [])
const { data } = useFetchData<Releases>('/data/releases.json')
const navigate = useNavigate()
const sitePaths = useContext(SitePathsContext)
const slug = useMatch(`${sitePaths.nouveautés}/:slug`)?.params?.slug
useEffect(hideNewsBanner, [])
useHideNewsBanner()
const releasesWithId = useMemo(
() => data && data.map((v, id) => ({ ...v, id })),
() => (data && data.map((v, id) => ({ ...v, id }))) ?? [],
[data]
)
if (data.length === 0) {
if (!data?.length) {
return null
}

View File

@ -0,0 +1,20 @@
import { Message } from '@/design-system'
import { Grid } from '@/design-system/layout'
import { Intro, Body } from '@/design-system/typography/paragraphs'
export default function Offline() {
return (
<Grid container css={{ justifyContent: 'center', margin: '10rem 0' }}>
<Grid item md={8} sm={12}>
<Message type="info" css={{ margin: '1rem 0' }}>
<Intro>Vous êtes actuellement hors ligne.</Intro>
<Body>
Cette page n'a pas encore été téléchargée et n'est donc pas
disponible sans internet, pour y accéder vérifiez votre connexion
puis rechargez la page.
</Body>
</Message>
</Grid>
</Grid>
)
}

View File

@ -2,11 +2,14 @@ import { H2, H3 } from '@/design-system/typography/heading'
import { Link } from '@/design-system/typography/link'
import { Li, Ul } from '@/design-system/typography/list'
import { Body } from '@/design-system/typography/paragraphs'
import { useFetchData } from '@/hooks/useFetchData'
import { useState } from 'react'
import styled from 'styled-components'
import stats from '@/data/stats.json'
import { StatsStruct } from './types'
export default function DemandeUtilisateurs() {
const { data: stats } = useFetchData<StatsStruct>('/data/stats.json')
return (
<section>
<H2 id="demandes-utilisateurs">Demandes utilisateurs</H2>
@ -22,10 +25,10 @@ export default function DemandeUtilisateurs() {
</Body>
<H3>En attente d'implémentation</H3>
<Pagination items={stats.retoursUtilisateurs.open} />
<Pagination items={stats?.retoursUtilisateurs.open ?? []} />
<H3>Réalisées</H3>
<Pagination items={stats.retoursUtilisateurs.closed} />
<Pagination items={stats?.retoursUtilisateurs.closed ?? []} />
</section>
)
}

View File

@ -2,11 +2,12 @@ import PagesChart from '@/components/charts/PagesCharts'
import InfoBulle from '@/components/ui/InfoBulle'
import Emoji from '@/components/utils/Emoji'
import { useScrollToHash } from '@/components/utils/markdown'
import statsJson from '@/data/stats.json'
import { Radio, ToggleGroup } from '@/design-system/field'
import { Item, Select } from '@/design-system/field/Select'
import { Grid, Spacing } from '@/design-system/layout'
import { H2, H3 } from '@/design-system/typography/heading'
import { Body, Intro } from '@/design-system/typography/paragraphs'
import { useFetchData } from '@/hooks/useFetchData'
import { formatValue } from 'publicodes'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Trans } from 'react-i18next'
@ -24,16 +25,9 @@ import SatisfactionChart from './SatisfactionChart'
import { Page, PageChapter2, PageSatisfaction, StatsStruct } from './types'
import { formatDay, formatMonth } from './utils'
const stats = statsJson as unknown as StatsStruct
type Period = 'mois' | 'jours'
type Chapter2 = PageChapter2 | 'PAM'
const chapters2: Chapter2[] = [
...new Set(stats.visitesMois?.pages.map((p) => p.page_chapter2)),
'PAM',
]
type Pageish = Page | PageSatisfaction
const isPAM = (name: string | undefined) =>
@ -116,7 +110,11 @@ interface BrushStartEndIndex {
endIndex?: number
}
const StatsDetail = () => {
interface StatsDetailProps {
stats: StatsStruct
}
const StatsDetail = ({ stats }: StatsDetailProps) => {
const defaultPeriod = 'mois'
const [searchParams, setSearchParams] = useSearchParams()
useScrollToHash()
@ -185,6 +183,11 @@ const StatsDetail = () => {
[slicedVisits]
)
const chapters2: Chapter2[] = [
...new Set(stats.visitesMois?.pages.map((p) => p.page_chapter2)),
'PAM',
]
return (
<>
<H2>Statistiques détaillées</H2>
@ -306,17 +309,21 @@ const Indicators = styled.div`
`
export default function Stats() {
const statsAvailable = stats.visitesMois !== undefined
const { data: stats, loading } = useFetchData<StatsStruct>('/data/stats.json')
const statsAvailable = stats?.visitesMois != null
return (
<>
{statsAvailable ? (
<>
<StatsDetail />
<StatsDetail stats={stats} />
<GlobalStats stats={stats} />
</>
) : loading ? (
<Intro>Chargement des statistiques...</Intro>
) : (
<p>Statistiques indisponibles.</p>
<Body>Statistiques indisponibles.</Body>
)}
<DemandeUtilisateurs />

View File

@ -1,12 +1,12 @@
import Emoji from '@/components/utils/Emoji'
import { ScrollToTop } from '@/components/utils/Scroll'
import { SitePathsContext } from '@/components/utils/SitePathsContext'
import jobOffers from '@/data/job-offers.json'
import { Banner, InnerBanner } from '@/design-system/banner'
import { Link } from '@/design-system/typography/link'
import { useFetchData } from '@/hooks/useFetchData'
import { useContext } from 'react'
import { Trans } from 'react-i18next'
import { Routes, Route, useLocation } from 'react-router-dom-v5-compat'
import { Route, Routes, useLocation } from 'react-router-dom-v5-compat'
import { TrackChapter } from '../../ATInternetTracking'
import API from './API'
import Iframe from './Iframe'
@ -23,7 +23,8 @@ type JobOffer = {
export default function Integration() {
const sitePaths = useContext(SitePathsContext)
const { pathname } = useLocation()
const openJobOffer = (jobOffers as Array<JobOffer>)[0]
const { data: jobOffers } = useFetchData<JobOffer[]>('/data/job-offers.json')
const openJobOffer = jobOffers?.[0]
return (
<TrackChapter chapter1="integration">

View File

@ -36,6 +36,24 @@ const HOUR = 60 * 60
const DAY = HOUR * 24
const YEAR = DAY * 365
const networkFirstJS = new Route(
({ sameOrigin, url }) => {
return sameOrigin && /assets\/.*\.js$/.test(url.pathname)
},
new NetworkFirst({
cacheName: 'js-cache',
plugins: [
new ExpirationPlugin({
maxAgeSeconds: 30 * DAY,
maxEntries: 40,
}),
],
fetchOptions: {},
})
)
registerRoute(networkFirstJS)
const staleWhileRevalidate = new Route(
({ request, sameOrigin, url }) => {
return (
@ -77,11 +95,12 @@ registerRoute(networkFirstPolyfill)
const networkFirstAPI = new Route(
({ sameOrigin, url }) => {
return (
!sameOrigin &&
[
'api.recherche-entreprises.fabrique.social.gouv.fr',
'geo.api.gouv.fr',
].includes(url.hostname)
(!sameOrigin &&
[
'api.recherche-entreprises.fabrique.social.gouv.fr',
'geo.api.gouv.fr',
].includes(url.hostname)) ||
(sameOrigin && /data\/.*\.json$/.test(url.pathname))
)
},
new NetworkFirst({

View File

@ -29,6 +29,7 @@
"test/**/*.ts",
"vite.config.ts",
"vite-iframe-script.config.ts",
"prerender.ts"
"prerender.ts",
"vite-pwa-options.ts"
]
}

48
site/vite-pwa-options.ts Normal file
View File

@ -0,0 +1,48 @@
import { Options } from 'vite-plugin-pwa'
export const pwaOptions: Partial<Options> = {
registerType: 'prompt',
strategies: 'injectManifest',
srcDir: 'source',
filename: 'sw.ts',
injectManifest: {
maximumFileSizeToCacheInBytes: 3000000,
manifestTransforms: [
(entries) => {
const manifest = entries.filter(
(entry) => !/assets\/.*(-legacy|lazy_)/.test(entry.url)
)
return { manifest }
},
],
},
includeAssets: [
'logo-*.png',
'fonts/*.{woff,woff2}',
'références-images/*.{jpg,png,svg}',
],
manifest: {
start_url: '/',
name: 'Mon entreprise',
short_name: 'Mon entreprise',
description: "L'assistant officiel du créateur d'entreprise",
lang: 'fr',
orientation: 'portrait-primary',
display: 'minimal-ui',
theme_color: '#2975d1',
background_color: '#ffffff',
icons: [
{
src: '/favicon/android-chrome-192x192-shadow.png?v=2.0',
sizes: '192x192',
type: 'image/png',
},
{
src: '/favicon/android-chrome-512x512-shadow.png?v=2.0',
sizes: '512x512',
type: 'image/png',
},
],
},
}

View File

@ -11,6 +11,7 @@ import { defineConfig, loadEnv, Plugin } from 'vite'
import { VitePWA } from 'vite-plugin-pwa'
import shimReactPdf from 'vite-plugin-shim-react-pdf'
import { runScriptOnFileChange } from './scripts/runScriptOnFileChange'
import { pwaOptions } from './vite-pwa-options'
const env = (mode: string) => loadEnv(mode, process.cwd(), '')
@ -21,7 +22,18 @@ export default defineConfig(({ command, mode }) => ({
},
publicDir: 'source/public',
build: {
sourcemap: true,
sourcemap: !true,
rollupOptions: {
output: {
chunkFileNames: (chunkInfo) => {
if (chunkInfo.isDynamicEntry) {
return 'assets/lazy_[name].[hash].js'
}
return 'assets/[name].[hash].js'
},
},
},
},
plugins: [
{
@ -54,7 +66,7 @@ export default defineConfig(({ command, mode }) => ({
"mon-entreprise.urssaf.fr : L'assistant officiel du créateur d'entreprise",
description:
'Du statut juridique à la première embauche, en passant par la simulation des cotisations, vous trouverez ici toutes les ressources pour démarrer votre activité.',
shareImage: 'https://mon-entreprise.urssaf.fr/logo-share.png',
shareImage: '/logo-share.png',
},
infrance: {
lang: 'en',
@ -63,48 +75,11 @@ export default defineConfig(({ command, mode }) => ({
'My company in France: A step-by-step guide to start a business in France',
description:
'Find the type of company that suits you and follow the steps to register your company. Discover the French social security system by simulating your hiring costs. Discover the procedures to hire in France and learn the basics of French labour law.',
shareImage:
'https://mon-entreprise.urssaf.fr/logo-mycompany-share.png',
shareImage: '/logo-mycompany-share.png',
},
},
}),
VitePWA({
registerType: 'prompt',
strategies: 'injectManifest',
srcDir: 'source',
filename: 'sw.ts',
injectManifest: {
maximumFileSizeToCacheInBytes: 3000000,
},
includeAssets: [
'logo-*.png',
'fonts/*.{woff,woff2}',
'références-images/*.{jpg,png,svg}',
],
manifest: {
start_url: '/',
name: 'Mon entreprise',
short_name: 'Mon entreprise',
description: "L'assistant officiel du créateur d'entreprise",
lang: 'fr',
orientation: 'portrait-primary',
display: 'minimal-ui',
theme_color: '#2975d1',
background_color: '#ffffff',
icons: [
{
src: '/favicon/android-chrome-192x192-shadow.png?v=2.0',
sizes: '192x192',
type: 'image/png',
},
{
src: '/favicon/android-chrome-512x512-shadow.png?v=2.0',
sizes: '512x512',
type: 'image/png',
},
],
},
}),
VitePWA(pwaOptions),
legacy({
targets: ['defaults', 'not IE 11'],
}),
@ -227,6 +202,7 @@ function multipleSPA(options: MultipleSPAOptions): Plugin {
config.build = {
...config.build,
rollupOptions: {
...config.build?.rollupOptions,
input: Object.fromEntries(
Object.keys(options.sites).map((name) => [
name,