Merge pull request #1385 from ae-utbm/localstorage

Automatic localstorage cleaning
This commit is contained in:
thomas girod
2026-05-22 08:21:36 +02:00
committed by GitHub
20 changed files with 273 additions and 99 deletions
@@ -1,4 +1,3 @@
import { exportToHtml } from "#core:utils/globals.ts";
import { newsDeleteNews, newsFetchNewsDates, newsPublishNews } from "#openapi";
// This will be used in jinja templates,
@@ -13,7 +12,8 @@ const AlertState = {
// biome-ignore lint/style/useNamingConvention: this feels more like an enum
DISPLAYED: 4, // When published at page generation
};
exportToHtml("AlertState", AlertState);
// biome-ignore lint/style/useNamingConvention: it's an enum, PascalCase is better
Object.assign(window, { AlertState });
document.addEventListener("alpine:init", () => {
Alpine.data("moderationAlert", (newsId: number) => ({
+1 -1
View File
@@ -4,7 +4,7 @@
<title>{% trans %}Slideshow{% endtrans %}</title>
<link rel="shortcut icon" href="{{ static('core/img/favicon.ico') }}">
<link href="{{ static('css/slideshow.scss') }}" rel="stylesheet" type="text/css" />
<script type="module" src="{{ static('bundled/alpine-index.ts') }}"></script>
<script type="module" src="{{ static('bundled/base-bundle-index.ts') }}"></script>
<script type="module" src="{{ static('bundled/com/slideshow-index.ts') }}"></script>
</head>
<body x-data="slideshow([
-21
View File
@@ -1,21 +0,0 @@
import sort from "@alpinejs/sort";
import { Alpine } from "alpinejs";
import { limitedChoices } from "#core:alpine/limited-choices";
import {
type NotificationPlugin,
notificationsPlugin as notifications,
} from "#core:utils/notifications";
declare module "alpinejs" {
interface Magics<T> {
$notifications: NotificationPlugin;
}
}
Alpine.plugin([sort, limitedChoices, notifications]);
// biome-ignore lint/style/useNamingConvention: it's how it's named
Object.assign(window, { Alpine });
window.addEventListener("DOMContentLoaded", () => {
Alpine.start();
});
+64
View File
@@ -0,0 +1,64 @@
/**
* File containing main functions and library re-exports
* that should be accessible throughout the whole website.
*
* The idea is to group all that shared code into a single bundle,
* for more efficient tree-shaking and gzip compression.
*/
import sort from "@alpinejs/sort";
import Alpine from "alpinejs";
import { polyfillCountryFlagEmojis } from "country-flag-emoji-polyfill";
import htmx from "htmx.org";
import { limitedChoices } from "#core:alpine/limited-choices";
import { expireOldStorage } from "#core:core/localstorage";
import { default as navbar } from "#core:core/navbar";
import {
type NotificationPlugin,
notificationsPlugin as notifications,
} from "#core:utils/notifications";
/**
* Alpine
*/
declare module "alpinejs" {
interface Magics<T> {
$notifications: NotificationPlugin;
}
}
Alpine.plugin([sort, limitedChoices, notifications]);
// biome-ignore lint/style/useNamingConvention: it's how it's named
Object.assign(window, { Alpine });
window.addEventListener("DOMContentLoaded", () => {
Alpine.start();
});
/**
* Polyfill for country flags (used for language choice)
*/
polyfillCountryFlagEmojis();
/**
* HTMX
*/
document.body.addEventListener("htmx:beforeRequest", (event: CustomEvent) => {
event.detail.target.ariaBusy = true;
});
document.body.addEventListener("htmx:beforeSwap", (event: CustomEvent) => {
event.detail.target.ariaBusy = null;
});
Object.assign(window, { htmx });
/**
* navbar
*/
navbar();
/**
* Script that clears the cache when the cache version changes
*/
expireOldStorage();
+72
View File
@@ -0,0 +1,72 @@
/**
* For more detailed infos on how to use this file,
* check /docs/tutorial/front/localstorage.md,
* or https://ae-utbm.github.io/sith/tutorial/front/localstorage/
*/
// increment this number when a breaking change is made with localStorage
const CURRENT_LOCALSTORAGE_VERSION = 1;
/**
* Remove keys that are no longer used from localStorage
*/
export function expireOldStorage() {
const version = Number.parseInt(localStorage.getItem("version") ?? "0", 10);
if (version === CURRENT_LOCALSTORAGE_VERSION) {
// The cache schema is up-to-date. Nothing to do.
return;
}
localStorage.removeItem("basket1");
// remove all storage items which key is in the form
// `userXXXPictures` or `userXXXPicturesNumber`
Object.keys(localStorage)
.filter(
(key) =>
key.startsWith("user") &&
(key.endsWith("Pictures") || key.endsWith("PicturesNumber")),
)
.forEach((key) => {
localStorage.removeItem(key);
});
localStorage.setItem("version", CURRENT_LOCALSTORAGE_VERSION.toString());
}
interface VersionedStorageItem<T> {
version?: number;
val: T | undefined;
}
export const versionedLocalStorage = {
...localStorage,
/**
* set this item in localStorage, alongside its version.
*
* Note: this expects an object, not a JSON string, because the parsing
* into JSON needs to be done inside the function.
*/
setItem<T>(key: string, value: T, { version }: { version: number }) {
localStorage.setItem(key, JSON.stringify({ version: version, val: value }));
},
/**
* Get the item linked with the given key and version from localStorage.
*
* Note: if the given key exists in localStorage but doesn't satisfy
* the given version, it will be cleared from cache.
*
* @return the object if found and with the good version, else null;
*/
getItem<T>(key: string, { version }: { version: number }): T | null {
const stored = localStorage.getItem(key);
if (!stored) {
// this key doesn't exist, return null;
return null;
}
const obj: VersionedStorageItem<T> = JSON.parse(stored);
if (obj.version !== version || obj.val === undefined) {
// The version is wrong, return null and remove this item from cache
localStorage.removeItem(key);
return null;
}
return obj.val;
},
};
@@ -1,12 +1,10 @@
import { exportToHtml } from "#core:utils/globals.ts";
exportToHtml("showMenu", () => {
function showMenu() {
const navbar = document.getElementById("navbar-content");
const current = navbar.getAttribute("mobile-display");
navbar.setAttribute("mobile-display", current === "hidden" ? "revealed" : "hidden");
});
}
document.addEventListener("alpine:init", () => {
function navbarInit() {
const menuItems = document.querySelectorAll(".navbar details[name='navbar'].menu");
const isDesktop = () => {
return window.innerWidth >= 500;
@@ -33,4 +31,9 @@ document.addEventListener("alpine:init", () => {
}
});
}
});
}
export default () => {
Object.assign(document, { showMenu });
document.addEventListener("alpine:init", navbarInit);
};
@@ -1,3 +0,0 @@
import { polyfillCountryFlagEmojis } from "country-flag-emoji-polyfill";
polyfillCountryFlagEmojis();
-11
View File
@@ -1,11 +0,0 @@
import htmx from "htmx.org";
document.body.addEventListener("htmx:beforeRequest", (event) => {
event.detail.target.ariaBusy = true;
});
document.body.addEventListener("htmx:beforeSwap", (event) => {
event.detail.target.ariaBusy = null;
});
Object.assign(window, { htmx });
+3 -3
View File
@@ -1,6 +1,5 @@
// biome-ignore lint/performance/noNamespaceImport: this is the recommended way from the documentation
import * as Sentry from "@sentry/browser";
import { exportToHtml } from "#core:utils/globals.ts";
interface LoggedUser {
name: string;
@@ -13,7 +12,7 @@ interface SentryOptions {
user?: LoggedUser;
}
exportToHtml("loadSentryPopup", (options: SentryOptions) => {
const loadSentryPopup = (options: SentryOptions) => {
Sentry.init({
dsn: options.dsn,
});
@@ -21,4 +20,5 @@ exportToHtml("loadSentryPopup", (options: SentryOptions) => {
eventId: options.eventId,
...(options.user ?? {}),
});
});
};
Object.assign(window, { loadSentryPopup });
+1 -1
View File
@@ -1,4 +1,4 @@
import type { NestedKeyOf } from "#core:utils/types.ts";
import type { NestedKeyOf } from "#core:types/nested-key";
interface StringifyOptions<T extends object> {
/** The columns to include in the resulting CSV. */
-14
View File
@@ -5,17 +5,3 @@ declare global {
const gettext: (text: string) => string;
const interpolate: <T>(fmt: string, args: string[] | T, isNamed?: boolean) => string;
}
/**
* Helper function to export typescript functions to regular html and jinja files
* Without it, you either have to use the any keyword and suppress warnings or do a
* very painful type conversion workaround which is only here to please the linter
*
* This is only useful if you're using typescript, this is equivalent to doing
* window.yourFunction = yourFunction
**/
// biome-ignore lint/suspicious/noExplicitAny: Avoid strange tricks to export functions
export function exportToHtml(name: string, func: any) {
// biome-ignore lint/suspicious/noExplicitAny: Avoid strange tricks to export functions
(window as any)[name] = func;
}
+2 -5
View File
@@ -35,12 +35,9 @@
<noscript><link rel="stylesheet" href="{{ static('bundled/fontawesome-index.css') }}"></noscript>
<script src="{{ url('javascript-catalog') }}"></script>
<script type="module" src="{{ static("bundled/core/navbar-index.ts") }}"></script>
<script type="module" src="{{ static("bundled/base-bundle-index.ts") }}"></script>
<script type="module" src="{{ static("bundled/core/components/include-index.ts") }}"></script>
<script type="module" src="{{ static('bundled/alpine-index.ts') }}"></script>
<script type="module" src="{{ static('bundled/htmx-index.js') }}"></script>
<script type="module" src="{{ static('bundled/country-flags-index.ts') }}"></script>
<script type="module" src="{{ static('bundled/core/tooltips-index.ts') }}"></script>
<script type="module" src="{{ static("bundled/core/tooltips-index.ts") }}"></script>
{% block additional_css %}{% endblock %}
{% block additional_js %}{% endblock %}
@@ -1,9 +1,9 @@
import { showSaveFilePicker } from "native-file-system-adapter";
import type TomSelect from "tom-select";
import type { NestedKeyOf } from "#core:types/nested-key";
import { paginated } from "#core:utils/api";
import { csv } from "#core:utils/csv";
import { getCurrentUrlParams, History, updateQueryString } from "#core:utils/history";
import type { NestedKeyOf } from "#core:utils/types";
import {
type ProductSchema,
type ProductSearchProductsDetailedData,
+75
View File
@@ -0,0 +1,75 @@
[Documentation du localStorage (mozilla)](https://developer.mozilla.org/fr/docs/Web/API/Window/localStorage)
## Utilité et limitations
Le `localStorage` est un cache géré directement par le navigateur.
Il permet de stocker des données directement chez le client.
Il s'agit donc d'un outil extrêmement puissant, qui permet d'éviter
beaucoup de requêtes au serveur, améliorant ainsi les temps de chargement.
Cependant, il y a deux limitations majeures à prendre en compte :
- le `localStorage` est entièrement géré par le client,
une fois le déploiement effectué, vous ne pouvez plus y toucher ;
vous devez donc être sûr de vous avant d'apporter des modifications
reposant sur le `localStorage`.
- la quantité de données stockable est limitée à 10Mo ;
une fois ce quota rempli, le navigateur lèvera une `QuotaExceededError`.
## Invalidation du `localStorage`
Pour résoudre le premier de ces deux problèmes, il y a un script permettant
d'annuler une partie du cache.
Ce dernier se trouve dans le fichier `core/static/bundled/core/cache.ts`.
Vous devrez modifier ce fichier chaque fois qu'un élément du localStorage
cessera d'être utilisé.
Les modifications à apporter sont les suivantes :
- incrémenter la version du cache
- ajouter une ligne permettant de retirer votre clef du cache
```ts hl_lines="2 11"
// increment this number when a breaking change is made with localStorage
const CURRENT_CACHE_VERSION = 2; // <-- changez cette ligne
export function cacheBuster() {
const version = Number.parseInt(localStorage.getItem("version") ?? "0", 10);
if (version === CURRENT_CACHE_VERSION) {
// The cache schema is up-to-date. Nothing to do.
return;
}
// ...
localStorage.removeItem("<clef>"); // <-- et rajoutez cette ligne
localStorage.setItem("version", CURRENT_CACHE_VERSION.toString());
}
```
## Versionnage d'une clef
Dans le cas où une paire clef-valeur du localStorage subit un changement
dans son schéma de données, utilisez `versionedLocalStorage` :
```typescript
import { versionedLocalStorage } from "#core:core/localstorage";
const foo = () => {
let obj = versionedLocalStorage.getItem("<key>", { version: 1 });
if (obj === null) {
obj = fetchMyObject();
versionedLocalStorage.setItem("<key>", obj, { version: 1 })
}
// Do something with obj...
}
```
!!!Warning
Il existe une différence d'usage entre `localStorage` et `versionedLocalStorage` :
les valeurs données à `localStorage` doivent être des strings (généralement
obtenus avec `JSON.stringify`), tandis que `versionedLocalStorage` utilise
directement des objets JS.
Cette différence résulte du fait que `versionedLocalStorage` doit légèrement
modifier les données pour y inclure la version, et gérer en interne
la conversion en JSON.
+10 -12
View File
@@ -1,4 +1,4 @@
export {};
import { versionedLocalStorage } from "#core:core/localstorage";
interface BasketItem {
priceId: number;
@@ -7,8 +7,8 @@ interface BasketItem {
unitPrice: number;
}
// increment the key number if the data schema of the cached basket changes
const BASKET_CACHE_KEY = "basket1";
const BASKET_CACHE_KEY = "basket";
const BASKET_CACHE_VERSION = 1;
document.addEventListener("alpine:init", () => {
Alpine.data("basket", (lastPurchaseTime?: number) => ({
@@ -34,18 +34,16 @@ document.addEventListener("alpine:init", () => {
},
loadBasket(): BasketItem[] {
if (localStorage.getItem(BASKET_CACHE_KEY) === null) {
return [];
}
try {
return JSON.parse(localStorage.getItem(BASKET_CACHE_KEY));
} catch (_err) {
return [];
}
const cached = versionedLocalStorage.getItem<BasketItem[]>(BASKET_CACHE_KEY, {
version: BASKET_CACHE_VERSION,
});
return cached ?? [];
},
saveBasket() {
localStorage.setItem(BASKET_CACHE_KEY, JSON.stringify(this.basket));
versionedLocalStorage.setItem(BASKET_CACHE_KEY, this.basket, {
version: BASKET_CACHE_VERSION,
});
localStorage.setItem("basketTimestamp", Date.now().toString());
},
+2
View File
@@ -66,6 +66,8 @@ nav:
- Gestion des permissions: tutorial/perms.md
- Gestion des groupes: tutorial/groups.md
- Les fragments: tutorial/fragments.md
- Frontend:
- localStorage: tutorial/front/localstorage.md
- API:
- Développement: tutorial/api/dev.md
- Connexion à l'API: tutorial/api/connect.md
+11
View File
@@ -44,6 +44,7 @@
"@biomejs/biome": "^2.4.15",
"@hey-api/openapi-ts": "^0.94.5",
"@types/alpinejs": "^3.13.11",
"@types/alpinejs__sort": "^3.13.0",
"@types/cytoscape-cxtmenu": "^3.4.5",
"@types/cytoscape-klay": "^3.1.5",
"@types/js-cookie": "^3.0.6",
@@ -2499,6 +2500,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/alpinejs__sort": {
"version": "3.13.0",
"resolved": "https://registry.npmjs.org/@types/alpinejs__sort/-/alpinejs__sort-3.13.0.tgz",
"integrity": "sha512-iR9vEy6e3yXbYAK45/hpulzlt8SSKTsvYUl/t5nuWjtbJPoGxzxUUqOm3egp83Gqtf//TyJnDCI4OTebAKDRAA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/alpinejs": "*"
}
},
"node_modules/@types/codemirror": {
"version": "5.60.17",
"resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.17.tgz",
+1
View File
@@ -29,6 +29,7 @@
"@biomejs/biome": "^2.4.15",
"@hey-api/openapi-ts": "^0.94.5",
"@types/alpinejs": "^3.13.11",
"@types/alpinejs__sort": "^3.13.0",
"@types/cytoscape-cxtmenu": "^3.4.5",
"@types/cytoscape-klay": "^3.1.5",
"@types/js-cookie": "^3.0.6",
+19 -19
View File
@@ -22,35 +22,35 @@ document.addEventListener("alpine:init", () => {
albums: [] as Album[],
async fetchPictures(): Promise<PictureSchema[]> {
const localStorageKey = `user${config.userId}Pictures`;
const localStorageInvalidationKey = `user${config.userId}PicturesNumber`;
const lastCachedNumber = localStorage.getItem(localStorageInvalidationKey);
// Check the cache before hitting the API.
const storageKey = "userPictures";
const cacheContent: { userId: number; pictures: PictureSchema[] }[] = JSON.parse(
sessionStorage.getItem(storageKey) || "[]",
);
const userPictures = cacheContent.find((obj) => obj.userId === config.userId);
if (
lastCachedNumber !== null &&
Number.parseInt(lastCachedNumber, 10) === config.nbPictures
userPictures !== undefined &&
userPictures.pictures.length === config.nbPictures
) {
return JSON.parse(localStorage.getItem(localStorageKey));
// The cached value is considered valid
// if it contains the right amount of pictures.
// This amount is known because it is given in the template.
return userPictures.pictures;
}
const pictures = await paginated(picturesFetchPictures, {
// biome-ignore lint/style/useNamingConvention: from python api
query: { users_identified: [config.userId] },
} as PicturesFetchPicturesData);
cacheContent.push({ userId: config.userId, pictures: pictures });
try {
localStorage.setItem(localStorageInvalidationKey, config.nbPictures.toString());
localStorage.setItem(localStorageKey, JSON.stringify(pictures));
// cache only the pictures of the last 4 visited profiles
sessionStorage.setItem(storageKey, JSON.stringify(cacheContent.slice(-4)));
} catch {
// an exception is raised if the localstorage is entirely filled
// so just delete all cached user pictures.
// an exception is raised if the storage is entirely filled.
// To be as safe as possible, delete the cached pictures.
// A cache hit is not worth the page breaking.
Object.keys(localStorage)
.filter(
(key) =>
key.startsWith("user") &&
(key.endsWith("Pictures") || key.endsWith("PicturesNumber")),
)
.forEach((key) => {
localStorage.removeItem(key);
});
sessionStorage.removeItem(storageKey);
}
return pictures;
},