Îlots & signaux
Une page PRISM est rendue en HTML sur le serveur : le code de vos composants n'est pas envoyé au navigateur. Quand une partie de la page doit réagir aux clics ou à la saisie, vous en faites un îlot : un composant rendu en HTML sur le serveur, puis rendu interactif dans le navigateur. Le reste de la page reste du HTML statique.
PRISM propose deux façons de faire :
- un composant
"use signals": vous l'écrivez en JSX avec des signaux, le serveur le rend et le compile pour le navigateur ; - un script écrit à la main dans
public/islands/, chargé par une balise<script>.
Déclarer un îlot à signaux
Un îlot est un fichier de src/components/ ou de src/islands/ (sous-dossiers compris) dont la
première ligne est la directive "use signals", suivie du pragma JSX des signaux :
// src/components/Counter.tsx
"use signals";
/** @jsxImportSource @bext-stack/framework/signals */
import { signal, computed } from "@bext-stack/framework/signals";
export default function Counter(props: { initial?: number }) {
const count = signal(props.initial ?? 0);
const doubled = computed(() => count.value * 2);
return (
<div>
<p>
Compteur : <strong>{count.value}</strong> (×2 = {doubled.value})
</p>
<button type="button" onClick={() => { count.value++; }}>+1</button>
</div>
);
}
Le nom de l'îlot est le nom du fichier sans extension (Counter ici). Le composant est
l'export par défaut du fichier.
L'insérer dans une page
Dans une page ou un layout ordinaire, appelez signalsIsland avec le nom de l'îlot, le
composant et ses props :
/** @jsxImportSource @bext-stack/framework */
import { signalsIsland } from "@bext-stack/framework/signals";
import Counter from "../components/Counter";
export async function loader() {
return { start: 3 };
}
export default function Page({ data }: { data: { start: number } }) {
return (
<main>
<h1>Démo</h1>
{signalsIsland("Counter", Counter, { initial: data.start })}
</main>
);
}
signalsIsland(name, Component, props?, opts?) exécute le composant sur le serveur et renvoie
son HTML dans un élément <bext-island>, avec les props sérialisées en JSON. Le résultat
s'insère tel quel dans le JSX ; <Raw html={...} /> fonctionne aussi.
Vous n'avez rien d'autre à brancher. Quand le HTML d'une page contient un <bext-island>, le
runtime ajoute un petit chargeur qui demande /islands/<nom>.js pour chaque îlot présent. Ce
script est compilé par le serveur à partir de votre fichier, sans étape de build. Dans le navigateur, il
exécute de nouveau le composant avec les mêmes props, puis attache les signaux, les attributs
réactifs et les gestionnaires d'événements au HTML déjà affiché, sans le reconstruire.
Les props venues du serveur
Les props passées à signalsIsland sont sérialisées avec JSON.stringify et écrites dans
l'attribut data-props de l'îlot. Le navigateur les relit pour exécuter le composant.
- Ne passez que des données JSON : chaînes, nombres, booléens,
null, tableaux et objets simples. Une fonction ou une valeurundefineddisparaît, uneDatearrive sous forme de chaîne. - Les props sont visibles dans le code source de la page. N'y mettez ni secret ni donnée que le visiteur ne doit pas voir.
- Chargez les données dans le loader de la page et passez le résultat en props : le composant d'un îlot est synchrone et ne peut pas attendre une promesse.
API des signaux
import {
signal, computed, effect, batch, untracked, isSignal,
List, Show, Switch, Match,
} from "@bext-stack/framework/signals";
| Fonction | Description |
|---|---|
signal(initial) |
Crée une valeur réactive. .value la lit ou l'écrit, .peek() la lit sans s'abonner. |
computed(fn) |
Valeur dérivée, recalculée à la lecture quand une de ses dépendances a changé. |
effect(fn) |
Exécute fn tout de suite, puis à chaque changement d'un signal lu. fn peut renvoyer une fonction de nettoyage. Renvoie une fonction qui arrête l'effet. |
batch(fn) |
Regroupe les écritures : chaque effet concerné ne s'exécute qu'une fois, à la fin. |
untracked(fn) |
Lit des signaux dans fn sans s'y abonner. |
isSignal(v) |
true pour un signal ou un computed. |
Une écriture sur .value ne notifie que si la nouvelle valeur est différente (comparaison
Object.is).
Ce qui est réactif dans le JSX
Dans un fichier "use signals", une expression JSX qui lit .value d'un signal déclaré dans
ce fichier par const x = signal(...) ou const x = computed(...) est rendue réactive
automatiquement : {count.value}, {items.value.length} ou value={state.value.q} se
mettent à jour seuls.
Ailleurs, rendez la réactivité explicite en passant le signal lui-même ou une fonction :
<span>{() => props.label + " : " + total.value}</span>
<input disabled={() => saving.value} />
Les gestionnaires s'écrivent onClick, onInput, onSubmit… et sont branchés avec
addEventListener sur l'événement en minuscules (click, input, submit).
Listes et conditions
<List each={todos} key={(t) => t.id}>
{(t) => <li>{() => t.title}</li>}
</List>
<Show when={() => !loading.value} fallback={() => <p>Chargement…</p>}>
{() => <p>{results.value.length} résultats</p>}
</Show>
<Switch fallback={() => <p>Aucun résultat</p>}>
<Match when={error}>{() => <p>Erreur</p>}</Match>
<Match when={() => results.value.length > 0}>{() => <ResultList />}</Match>
</Switch>
<List>prend un signal de tableau (ou une fonction qui renvoie un tableau) et une fonction de rendu par élément. Aveckey, seuls les éléments dont la clé est nouvelle sont rendus ; ceux dont la clé existait déjà gardent leur nœud (focus et défilement compris) et ne sont pas refaits, même si leurs données ont changé. Sanskey, toute la liste est reconstruite à chaque changement.<Show>rend son contenu quandwhenest vrai, sinonfallback. Le contenu est une fonction, réexécutée à chaque bascule.<Switch>rend la première<Match>dontwhenest vrai, sinonfallback.
Hydratation différée
Par défaut, un îlot devient interactif dès le chargement de son script. Le quatrième argument
de signalsIsland le diffère :
{signalsIsland("Counter", Counter, { initial: 2 }, { lazy: "visible" })}
lazy |
L'îlot est hydraté… |
|---|---|
| absent | dès le chargement de son script |
"visible" |
quand il approche de la zone visible (200 px avant) |
"idle" |
quand le navigateur est inactif (requestIdleCallback) |
"interaction" |
au premier pointerdown, focusin, keydown ou touchstart sur l'îlot |
Le HTML rendu par le serveur reste affiché pendant l'attente : seule l'interactivité est
retardée. Sans IntersectionObserver pour "visible", ou sans requestIdleCallback pour
"idle", l'îlot est hydraté tout de suite. Une valeur inconnue est ignorée.
Scripts écrits à la main dans public/islands/
Pour un comportement qui n'a pas besoin de JSX côté client (un tiroir de panier, une recherche,
un formulaire amélioré), un script JavaScript simple suffit souvent. Placez-le dans
public/islands/ et chargez-le depuis la page :
<script src="/islands/cart.js?v=6" defer></script>
Le serveur rend le HTML complet ; le script ne fait qu'ajouter le comportement. Ces fichiers sont servis tels quels : pas de compilation, pas de TypeScript, pas d'import de paquets npm.
Cache et versions
- Îlot
"use signals":/islands/<nom>.jsest servi avecCache-Control: no-cache. Le navigateur vérifie la version à chaque chargement : rien à versionner. - Fichier de
public/islands/: servi par défaut avec revalidation (max-age=0, must-revalidateet unETag). Ces fichiers ne sont pas surveillés par le rechargement automatique : pour forcer le rechargement d'un script, ajoutez un paramètre?v=à son adresse et augmentez-le à chaque modification.
Gardez la version de chaque script à un seul endroit, par exemple une petite fonction qui construit l'adresse :
// src/lib/islands.ts
const VERSIONS: Record<string, number> = { cart: 6, search: 3 };
export function islandSrc(name: string): string {
return "/islands/" + name + ".js?v=" + (VERSIONS[name] ?? 1);
}
Pièges
Le composant s'exécute deux fois : sur le serveur pour produire le HTML, puis dans le
navigateur pour rebrancher la réactivité. Les deux exécutions doivent suivre le même chemin.
Un rendu qui dépend de Math.random(), de l'heure ou de window produit des marqueurs qui ne
correspondent plus, et les liaisons concernées restent inertes, sans erreur.
effects'exécute aussi sur le serveur, dès sa création. Placez le code propre au navigateur (fetchvers votre API,localStorage, minuteries) dans les gestionnaires d'événements, ou protégez-le partypeof window !== "undefined".- Les chaînes ne sont pas échappées : dans un îlot, un enfant JSX de type chaîne est inséré
tel quel dans le HTML. Une valeur réactive (
{() => user.name}) est échappée. Passez par une fonction pour tout texte saisi par un utilisateur. - Remplacez, ne modifiez pas :
state.value.items.push(x)ne notifie personne. Écrivez une nouvelle valeur :state.value = { ...state.value, items: [...state.value.items, x] }. - La directive doit ouvrir le fichier : ni commentaire ni import avant
"use signals", sinon le fichier n'est pas reconnu comme îlot. - Export par défaut obligatoire : un composant exporté seulement par son nom ne s'hydrate pas.
- Le composant renvoie du JSX : pas de
computed(() => <div />)en valeur de retour. Placez la réactivité dans l'arbre, avec<Show>,<List>ou des fonctions. - Un nom par îlot : deux fichiers d'îlot ne doivent pas porter le même nom, et un fichier
public/islands/<nom>.jsest servi à la place de l'îlot compilé du même nom. - Module partagé entre îlots : dans un fichier d'aide importé par plusieurs îlots, importez
les primitives depuis
@bext-stack/framework/signals/core.
Et ensuite
| Page | Ce qu'elle couvre |
|---|---|
| Présentation du SDK | Le modèle de composant et la structure d'un site |
| Routage & Rendu | L'arbre de routes src/app/, ISR/SSR, le streaming |
| Loaders & Actions | Charger les données à passer en props, traiter les formulaires |
| Déployer un site PRISM | watch_dirs, rechargement automatique et public/islands |