Î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 valeur undefined disparaît, une Date arrive 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. Avec key, 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é. Sans key, toute la liste est reconstruite à chaque changement.
  • <Show> rend son contenu quand when est vrai, sinon fallback. Le contenu est une fonction, réexécutée à chaque bascule.
  • <Switch> rend la première <Match> dont when est vrai, sinon fallback.

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>.js est servi avec Cache-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-revalidate et un ETag). 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

Attention

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.

  • effect s'exécute aussi sur le serveur, dès sa création. Placez le code propre au navigateur (fetch vers votre API, localStorage, minuteries) dans les gestionnaires d'événements, ou protégez-le par typeof 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>.js est 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