Aller au contenu
SuperScheduler

ProductionS’applique àLite et Pro

Virtualisation et performances

Les deux éditions virtualisent dans les deux dimensions : seules les lignes et les dates autour de la zone visible ont des nœuds DOM, et le défilement, le zoom et le glisser mettent ce DOM à jour directement, sans rendu React. Dans une intégration, le temps part dans vos hooks de rendu, les slots de rendu React, la transformation des données et les props dont l’identité change à chaque rendu. Limitez les hooks à des recherches peu coûteuses, gardez props et objets événements stables, et mesurez des builds de production avec bridage du CPU.

Vérifié avec la v0.1.0 · relu le 7 octobre 2026.md

SuperScheduler est conçu pour des plannings de milliers de lignes et de centaines de milliers d’événements. Le moteur effectue un rendu DOM impératif : React l’héberge, et votre arbre React ne se rend que lorsque vos propres props ou votre état changent. Ce guide explique ce que le moteur fait pour vous, où une intégration peut encore perdre du temps, et comment mesurer honnêtement.

La virtualisation s’applique aux deux éditions. Lite virtualise lignes, jours et événements avec les mêmes index internes et ne déclenche jamais de rendu React pendant le défilement. Les hooks de rendu, le zoom, le niveau de détail et les slots de rendu React sont des fonctions Pro : les sections qui en parlent ne concernent donc que Pro.

Fonctionnement de la virtualisation

La fenêtre montée

Seule une fenêtre autour de la zone visible a des nœuds DOM : la zone visible plus une marge, alignée sur des blocs d’un quart de la zone visible, avec deux blocs de marge de chaque côté. La fenêtre est recalculée à chaque événement de défilement mais ne change que lorsqu’une limite de bloc est franchie. Sur une image de défilement, la zone visible plus un bloc sont peints de façon synchrone ; le reste de la fenêtre est peint progressivement sur les images suivantes, lignes les plus proches d’abord. Les appels comme control.update() font un rendu synchrone et ne sont jamais étalés sur plusieurs images.

Les lignes

Les ressources sont aplaties en lignes une seule fois (lignes d’arborescence comprises), et les hauteurs de lignes vivent dans un index de sommes préfixes : trouver les lignes correspondant à une position de défilement est logarithmique, sans parcours de toutes les lignes. Replier, déplier ou filtrer reconstruit la liste des lignes visibles en une passe. Une ligne dont la hauteur change décale les lignes situées en dessous par tranches entières, au lieu de restyler chaque cellule et chaque événement.

Les cellules

Les lignes de grille et l’ombrage des week-ends ou du temps non ouvré sont peints comme un arrière-plan répété : les cellules ordinaires n’ont donc aucun nœud DOM. Une cellule ne reçoit un nœud que si elle est personnalisée (par onBeforeCellRender ou renderCell) et se trouve dans la fenêtre montée. Quand vous dézoomez en dessous de 2 px par cellule, les cellules personnalisées ne sont pas créées et leur hook n’est pas appelé.

Les événements

Les événements sont indexés par ressource et par temps : une plage visible se trouve par une requête logarithmique. L’empilement des chevauchements est calculé à partir des horaires, pas des pixels : zoomer ne réempile donc jamais les lignes. Les nœuds d’événements proviennent d’un pool et sont réutilisés à mesure que la fenêtre se déplace. Aux petites tailles, le niveau de détail fait passer les événements du contenu complet au texte, puis à de simples blocs et enfin à une barre par ligne, et masque les liens dont les extrémités sont trop petites pour être vues. lod: false désactive cette adaptation et coûte plus cher en vue dézoomée.

Aucun rendu React pendant l’interaction

Les images de défilement, de zoom, de survol, de sélection et de glissement sont gérées par le moteur, avec une géométrie en cache et un ordonnanceur d’images unique qui sépare les lectures de mise en page des écritures dans le DOM. Le contenu React de super-scheduler/react-render est la seule exception, par conception : le repli HTML ou texte est peint en premier, et le contenu React est publié après la fin de l’interaction, par lots qui visent 8 ms. Les fonctions optionnelles comme la navigation au clavier, les menus et les bulles se chargent sous forme de chunks séparés quand vous les configurez ou les utilisez pour la première fois, jamais pendant un geste.

Planification des postes à quaiUn navire arrive avec douze heures de retard. Déplacez sa fenêtre d’accostage, puis entraînez son remorqueur et ses grues.

Ce qui coûte du temps dans une intégration

La bibliothèque ne peut pas rendre vos callbacks moins coûteux. Voici les endroits où les intégrations réelles perdent du temps.

Les hooks de rendu

HookQuand il s’exécuteEn cache jusqu’à
onBeforeEventRenderPour chaque événement dont la mise en page a besoin, pas seulement les visibles, car il peut modifier height, line ou hiddenUn changement des données de l’événement
onBeforeCellRenderPour chaque cellule de la fenêtre montée, au-dessus de 2 px par celluleDe nouvelles resources, control.update(), ou un changement des événements de la ligne quand la ressource a cellsAutoUpdated: true
onBeforeRowHeaderRenderPour les en-têtes de ligne montésUn changement de la ligne ou des données de sa ressource
onBeforeTimeHeaderRenderPour les cellules d’en-tête montéesUn changement de l’axe du temps (échelle, niveau de zoom, dates)
onEventMoving, onEventResizing, onTimeRangeSelectingÀ chaque changement de l’ombre pendant un gesteJamais mis en cache

Passer une nouvelle fonction pour un hook de rendu vide aussi son cache. Limitez les hooks à des recherches et à la construction de chaînes. Précalculez maps et sets hors du hook, créez les formateurs Intl une seule fois au niveau du module, ne lisez jamais la mise en page (getBoundingClientRect) et ne modifiez jamais d’état React dans un hook. Pendant un glissement, args.conflicts est calculé au premier accès : ne le lisez donc pas si la règle n’en a pas besoin.

Les props dont l’identité change

Le composant React ne transmet que les props dont l’identité a changé depuis le dernier rendu (Object.is). Chaque prop transmise a un coût :

  • Un nouveau tableau events dont les objets diffèrent de ceux du contrôle recharge tout le store d’événements et réexécute onBeforeEventRender pour chaque événement. Renvoyer les mêmes objets ([...args.events] depuis onEventsChange) est reconnu comme un écho et évite le rechargement.
  • Un nouveau tableau resources reconstruit les lignes et invalide toutes les cellules.
  • Une nouvelle fonction de hook invalide le cache de ce hook.
  • De nouveaux objets timeHeaders, zoomLevels, classNames ou styles sont appliqués à nouveau.

Définissez les constantes au niveau du module, mémoïsez les props dérivées avec useMemo, et enveloppez dans useCallback les handlers qui dépendent de l’état. Une prop qui disparaît entre deux rendus revient à sa valeur par défaut : gardez donc aussi les props conditionnelles stables.

src/StablePlanning.tsxtsx
import { useCallback, useMemo, useState } from 'react'
import { SuperScheduler, SuperSchedulerComponent } from 'super-scheduler'
import type {
  SchedulerBeforeCellRenderArgs,
  SchedulerBeforeEventRenderArgs,
  SchedulerEventsChangeArgs,
} from 'super-scheduler'

type Stay = { status: 'confirmed' | 'tentative'; guests: number }

// Module scope: created once for the life of the page.
const TIME_HEADERS: SuperScheduler.TimeHeaderData[] = [
  { groupBy: 'Month' },
  { groupBy: 'Day', format: 'd' },
]
const STATUS_CLASS = {
  confirmed: 'stay stay--confirmed',
  tentative: 'stay stay--tentative',
} as const

// Runs for every event the layout needs, then is cached per event: keep it to lookups and strings.
function onBeforeEventRender(args: SchedulerBeforeEventRenderArgs) {
  const data = args.data as SuperScheduler.EventRenderData<Stay>
  data.cssClass = STATUS_CLASS[data.status]
  data.html = `${SuperScheduler.Util.escapeHtml(data.text)} <small>${data.guests}</small>`
}

interface Props {
  readonly rooms: SuperScheduler.ResourceData[]
  readonly initial: SuperScheduler.EventData<Stay>[]
  /** Days the hotel is closed, as `yyyy-MM-dd`. */
  readonly closedDays: readonly string[]
}

export function StablePlanning({ rooms, initial, closedDays }: Props) {
  // Map server data to event objects once; new objects on every render would reload the store.
  const [events, setEvents] = useState(initial)
  const owned = useMemo(() => events.slice(), [events])

  // A Set built when its input changes, so the cell hook is a constant-time lookup.
  const closed = useMemo(() => new Set(closedDays), [closedDays])
  const onBeforeCellRender = useCallback(
    (args: SchedulerBeforeCellRenderArgs) => {
      if (closed.has(args.cell.start.toString('yyyy-MM-dd'))) args.cell.properties.disabled = true
    },
    [closed],
  )

  // The same objects handed back are recognized as an echo: no reload, no repaint.
  const onEventsChange = useCallback((args: SchedulerEventsChangeArgs) => {
    setEvents([...args.events] as SuperScheduler.EventData<Stay>[])
  }, [])

  return (
    <SuperSchedulerComponent
      startDate="2026-01-01"
      days={365}
      scale="Day"
      cellWidth={40}
      timeHeaders={TIME_HEADERS}
      resources={rooms}
      events={owned}
      onEventsChange={onEventsChange}
      onBeforeEventRender={onBeforeEventRender}
      onBeforeCellRender={onBeforeCellRender}
    />
  )
}

Les slots de rendu React

renderEvent, renderRowHeader et les autres props de rendu montent du contenu React via des portails dans les nœuds du moteur. Ils conviennent bien aux événements et aux en-têtes. renderCell monte un slot React par cellule montée, ce qui s’accumule vite sur une grille dense ; préférez les chaînes de onBeforeCellRender pour les grandes grilles et réservez React aux cellules qui ont besoin d’interaction. Mémoïsez les fonctions de rendu, et n’ajustez renderOptions.retain (éléments détachés conservés ; par défaut, le plus petit de 2 000 ou du double du nombre d’éléments montés) et renderOptions.sliceMs (durée visée par lot, 8 ms par défaut) qu’après avoir mesuré. Voir Slots de rendu React.

Les changements de données et votre propre état

  • control.events.add, update et remove sont incrémentaux et conviennent aux modifications isolées. Pour des centaines de modifications d’un coup, comme un import ou un rafraîchissement depuis le serveur, passez au planificateur un seul nouveau tableau au lieu de les appeler en boucle.
  • control.update() sans arguments est un rafraîchissement complet. Ne passez que les options qui ont changé.
  • onZoom s’exécute à chaque image d’un geste de zoom. Écrivez le retour visuel image par image dans le DOM, et ne mettez à jour l’état React que lorsque args.phase === 'end'.
  • useScheduler({ track: [...] }) de super-scheduler/hooks publie une fois les changements stabilisés, jamais à chaque image. Ne suivez que les sujets qu’un composant affiche.
  • Les parents d’arborescence repliés réduisent le travail de montage : la mise en page est calculée pour les lignes dépliées.

Liste de vérification

  • Importez le planificateur sur les routes qui l’utilisent, pour que son code reste hors du reste de votre application (Rendu serveur).
  • Gardez events, resources, timeHeaders, zoomLevels, classNames et les hooks stables d’un rendu à l’autre.
  • Transformez les lignes renvoyées par le serveur en objets événements une fois par réponse, pas pendant le rendu.
  • Donnez au contrôle sa propre copie du tableau d’événements (useMemo(() => events.slice(), [events])), car il modifie ce tableau sur place avec splice.
  • Limitez onBeforeEventRender et onBeforeCellRender à des recherches ; ne définissez cellsAutoUpdated que sur les lignes dont les cellules dépendent de leurs événements.
  • Préférez les hooks qui renvoient des chaînes à renderCell sur les grilles denses.
  • Regroupez les gros changements de données en un seul nouveau tableau.
  • Chargez les longues frises par plages avec super-scheduler/ranges au lieu d’envoyer des années de données.
  • Laissez lod activé, sauf si vous avez besoin d’un rendu littéral à tous les niveaux de zoom.
  • Ne modifiez jamais l’état React depuis des callbacks appelés à chaque image.

Mesurer

La bibliothèque est mesurée avec une méthode reproductible, et cette même méthode fonctionne pour votre intégration :

  • Builds de production. Les builds de développement de React et de votre application sont plus lents et ajoutent des vérifications.
  • Phases séparées. Générez ou récupérez les données avant le montage, puis mesurez le temps de montage avec une mise en page forcée juste après.
  • Des images, pas des moyennes. Relevez les p50, p95 et p99 du temps par image en faisant défiler à la molette sur la grille, en diagonale, pendant le défilement automatique et à plusieurs largeurs de zoom. Comptez les nœuds DOM montés et la mémoire après le ramasse-miettes.
  • Commits React. Enveloppez le planificateur dans un <Profiler> et vérifiez que le défilement, le zoom et le glissement ne provoquent aucun commit. onRender se déclenche dans les builds de développement ; en production, il nécessite le build de profilage de React.
  • CPU bridé. Recommencez avec un bridage du CPU à 4× dans les outils de performance du navigateur, et sur les appareils de vos utilisateurs.
  • Isolez vos callbacks. Comparez l’absence de hook, un hook vide et votre hook pour voir ce que coûte votre code.
  • Répétez les mesures. Un seul échantillon lent près d’un seuil, c’est du bruit. Comparez les médianes de plusieurs exécutions sur une machine par ailleurs inactive.

super-scheduler/datasets génère les mêmes scénarios déterministes que ceux qu’utilise la bibliothèque, pour reproduire une charge sans votre backend :

src/ScrollProfile.tsxtsx
import { Profiler, useRef, useState } from 'react'
import { SuperSchedulerComponent } from 'super-scheduler'
import type { SuperScheduler } from 'super-scheduler'
import { generateScenario, toSuperSchedulerData } from 'super-scheduler/datasets'

// Deterministic data generated before mounting, so generation is not measured as mount time.
// S2: 1,000 rows over 730 days from 2026-01-01, about 40,000 events. Same seed, same data.
const S2 = toSuperSchedulerData(generateScenario('S2'))
type DatasetResource = (typeof S2.resources)[number]

// The generator's resource type is an interface without an index signature, so it is not directly
// assignable to ResourceData: copy the fields the scheduler needs instead of casting.
const toResource = (resource: DatasetResource): SuperScheduler.ResourceData => ({
  id: resource.id,
  name: resource.name,
  ...(resource.expanded === undefined ? {} : { expanded: resource.expanded }),
  ...(resource.frozen === undefined ? {} : { frozen: resource.frozen }),
  ...(resource.children === undefined ? {} : { children: resource.children.map(toResource) }),
})
const RESOURCES = S2.resources.map(toResource)

export function ScrollProfile() {
  const [events] = useState(() => S2.events.slice())
  const commits = useRef(0)
  const counter = useRef<HTMLOutputElement>(null)

  // Written straight to the DOM: a state update here would itself cause the renders we count.
  const onRender = () => {
    commits.current += 1
    if (counter.current !== null) counter.current.textContent = `${commits.current} React commits`
  }

  return (
    <>
      <output ref={counter}>0 React commits</output>
      <Profiler id="planning" onRender={onRender}>
        <SuperSchedulerComponent
          startDate="2026-01-01"
          days={730}
          scale="Day"
          cellWidth={32}
          treeEnabled
          heightSpec="Fixed"
          height={640}
          resources={RESOURCES}
          events={events}
        />
      </Profiler>
    </>
  )
}

Vous devriez voir le compteur s’arrêter après le montage initial : faire défiler les 1 000 lignes et les deux années du planning n’ajoute aucun commit React.

Mesures publiées

La revue de performances de la bibliothèque du 2026-10-07 a enregistré ces résultats. Méthode : Chromium 145 headless avec rastérisation logicielle, zone d’affichage de 1440 × 900 avec un device pixel ratio de 1, la démo de la bibliothèque en build de production avec le build de profilage de React, sur un Apple M5 Pro de 24 Gio qui faisait tourner d’autres applications. Les temps par image sont des intervalles de requestAnimationFrame sur un écran d’environ 120 Hz pendant les parcours de défilement du banc de test : 8,3 ms correspond donc à l’intervalle d’image de l’écran lui-même. Le montage de S1 est la médiane de trois montages ; les scénarios plus lourds ont été montés une seule fois.

ScénarioDonnéesMontageImage p50 / p95 / p99Nœuds DOMTas après GC
S1120 lignes, 730 jours, environ 6 000 événements23,2 ms8,3 / 9,1 / 9,3 ms2 50611,2 Mio
S1, CPU 4×Identiques104,6 ms16,1 / 25,2 / 25,9 ms2 50611,2 Mio
S35 000 lignes, 1 500 jours, 200 021 événements291,6 ms8,3 / 9,2 / 9,4 ms2 035205,4 Mio
S3DenseComme S3, sans aucune nuit libre dans aucune chambre, 1 634 510 événements2 174,8 ms8,3 / 9,3 / 16,8 ms3 7381 589,2 Mio

Chaque scénario a enregistré zéro rendu React pendant le défilement. Ces chiffres proviennent d’une seule machine, un seul jour, avec l’application de démonstration de la bibliothèque et ses hooks ; ce sont des observations, pas des garanties pour votre intégration.

Limites

Le DOM reste petit quelle que soit la taille, mais le temps de montage et la mémoire augmentent avec le nombre total d’événements, car la mise en page est calculée pour chaque ligne dépliée lors de la construction de la grille. La ligne S3Dense ci-dessus montre le plafond : plus de deux secondes de montage et environ 1,6 Gio de tas pour 1,6 million d’événements. Bien avant cela, chargez par plages et ne gardez que les dates sur lesquelles on travaille. Il n’existe pas encore d’API de mutation en masse : les très grosses mises à jour en direct s’appliquent donc de préférence sous forme d’un seul nouveau tableau d’événements. Les liens nombreux ajoutent aussi du travail de peinture, car chaque peinture prend en compte tous les liens.

Guides associés : Intégration React, État contrôlé, Échelles de temps et zoom et Dépannage.