6.9 KiB
Intégration frameworks (SSR / RSC) — @aphrody/m3-react
Guide d'intégration des wrappers Material 3 dans un framework React à rendu serveur — Next.js App Router, Remix, ou tout setup RSC / SSR / hydration. Les composants sont des web components Lit, donc client-only : ils ne rendent rien côté serveur (le shadow DOM n'existe qu'au navigateur) et s'« upgradent » après l'hydration. Les exemples ci-dessous utilisent Next.js App Router (le cas le plus contraint), mais les principes valent pour tout SSR.
L'exemple du monorepo (examples/showcase/) est
bun-natif (pas Next) ; ce guide couvre les spécificités Next.js App Router
côté consommateur. Le package se consomme tel quel dans une app Next.
1. Ce que le package livre
Le package est pré-buildé dans dist/ (bun run build, via tsup + tsc) :
- ESM (
dist/index.js,dist/transitions/index.js) — pas de.tsbrut, donc consommable depuisnode_modulessanstranspilePackages. - Chaque chunk
.jsporte la directive'use client';en tête → Next RSC traite tout le package comme des client components, sans erreur « You're importing a component that needs … ». - Types (
dist/**/*.d.ts). react,react-domet@material/websont externes (peer deps / résolus chez le consommateur). Les imports profonds@material/web/<dir>/<el>.jssont conservés tels quels : c'est l'import du wrapper qui déclenche lecustomElements.define()(effet de bord) côté client.
package.json pointe main/module/types/exports sur dist/, déclare
sideEffects (les imports d'éléments enregistrent les custom elements — ne pas
tree-shaker) et files: ["dist"].
"exports": {
".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"./transitions":{ "types": "./dist/transitions/index.d.ts", "default": "./dist/transitions/index.js" }
}
2. Boundaries client / serveur
Règle : tout ce qui touche un Md… vit dans un fichier 'use client'.
layout.tsx/page.tsxrestent des Server Components (pas de directive) ; ils importent le CSS de thème et rendent un îlot client.- L'îlot client (ex.
demo.tsx) porte'use client', importe les wrappers, gère state/refs/handlers.
// app/page.tsx — Server Component
import { Demo } from "./demo";
export default function Home() {
return (
<main>
<Demo />
</main>
);
}
// app/demo.tsx — Client island
"use client";
import {
MdFilledButton,
MdOutlinedTextField,
MdDialog,
MdLineChart,
MdTabs,
MdPrimaryTab,
} from "@aphrody/m3-react";
import { Fade } from "@aphrody/m3-react/transitions";
import { useRef, useState } from "react";
export function Demo() {
const dialogRef = useRef<React.ComponentRef<typeof MdDialog>>(null);
const [name, setName] = useState("");
return (
<>
<MdFilledButton onClick={() => dialogRef.current?.show()}>Ouvrir</MdFilledButton>
<MdOutlinedTextField
label="Nom"
value={name}
onInput={(e) => setName((e.target as HTMLInputElement).value)}
/>
<MdTabs>
<MdPrimaryTab>A</MdPrimaryTab>
<MdPrimaryTab>B</MdPrimaryTab>
</MdTabs>
<MdLineChart
smooth
showMarkers
categories={["Jan", "Fév", "Mar"]}
series={[{ label: "CA", data: [12, 19, 8] }]}
/>
<MdDialog ref={dialogRef}>
<div slot="headline">Titre</div>
<div slot="content">Ouvert via ref impérative.</div>
<div slot="actions">
<MdFilledButton onClick={() => dialogRef.current?.close()}>Fermer</MdFilledButton>
</div>
</MdDialog>
</>
);
}
Refs impératives (dialog, etc.)
Le ref d'un wrapper pointe sur l'instance du custom element (avec ses
méthodes show() / close() / returnValue…). Pour typer le ref sans importer
les sources de @material/web, dérivez-le du composant :
const dialogRef = useRef<React.ComponentRef<typeof MdDialog>>(null);
dialogRef.current?.show();
Propriétés objet (charts, etc.)
@lit/react passe les props objet/tableau comme propriétés de l'élément
(pas des attributs). <MdLineChart series={[…]} categories={[…]} /> règle donc
.series / .categories directement — pas de JSON à sérialiser.
3. SSR / hydration + parade FOUC
Côté serveur, un <md-filled-button> se rend en élément non-:defined (pas de
shadow DOM). Aucun crash SSR : la page se pré-rend en statique (vérifié,
next build → route / en ○ Static). Pas de hydration mismatch tant que le
markup serveur = markup client (c'est le cas, le wrapper rend juste la balise).
Pour éviter le flash of unstyled content (l'élément brut visible avant l'upgrade), masquez les éléments tant qu'ils ne sont pas définis :
/* app/theme.css — FOUC parade */
:not(:defined) {
visibility: hidden;
}
Optionnellement, révélez après upgrade complet via customElements.whenDefined
dans un useEffect (cf. examples/showcase).
Tokens de thème
Définissez les --md-sys-* au niveau :root (ou body) dans un CSS importé par
le layout serveur :
:root {
--md-sys-color-primary: #6750a4;
--md-sys-color-on-primary: #fff;
/* … générés depuis @aphrody/m3-tokens en prod … */
}
4. Fallback : consommer les sources (transpilePackages)
Le dist pré-buildé fonctionne sans config. Si vous préférez consommer les
sources TS du package (ou épingler @material/web sur ses sources .ts,
p. ex. en monorepo lié par symlink), activez transpilePackages pour que Next
les compile :
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
transpilePackages: ["@aphrody/m3-react", "@material/web"],
};
export default nextConfig;
En monorepo, le lien workspace expose aussi les
.tsde@material/webà côté des.js/.d.ts. LemoduleResolution: "bundler"de TS résout alors…/x.jsvers la source.ts(décorateurs → erreur de typecheck). Deux parades : soittranspilePackagesci-dessus, soit ne typer que les artefacts publiés (l'app d'exemple redirige@material/web/*vers une copie.js+.d.ts-only viacompilerOptions.paths, exactement ce qu'unnpm installproduirait).
5. Checklist build
# 1. builder la lib + le package react (turbo)
bun run build # à la racine du monorepo → @material/web .js + dist react
# 2. dans VOTRE app Next.js : importer les wrappers (déjà marqués 'use client')
# import { MdFilledButton } from "@aphrody/m3-react";
# next build → vert, sans transpilePackages
Le monorepo n'embarque plus d'exemple Next.js dédié : l'exemple de référence est
examples/showcase(bun-natif). Ce guide reste valable pour intégrer les wrappers dans votre app Next.js App Router.