Saltar al contenido
SuperScheduler

ProducciónSe aplica aLite y Pro

Virtualización y rendimiento

Las dos ediciones virtualizan en dos dimensiones: solo las filas y las fechas cercanas al área visible tienen nodos DOM, y el scroll, el zoom y el arrastre actualizan ese DOM directamente, sin renders de React. En una integración, el tiempo se va en tus hooks de render, en los slots de renderizado React, en la transformación de datos y en las props cuya identidad cambia en cada render. Limita los hooks a búsquedas baratas, mantén estables las props y los objetos de evento, y mide builds de producción con la CPU ralentizada.

Verificado con v0.1.0 · revisado el 7 de octubre de 2026.md

SuperScheduler está pensado para planes con miles de filas y cientos de miles de eventos. El motor es un renderizador DOM imperativo: React lo aloja, y tu árbol de React solo se renderiza cuando cambian tus propias props o tu estado. Esta guía explica qué hace el motor por ti, dónde puede seguir gastando tiempo una integración y cómo medir con honestidad.

La virtualización se aplica a las dos ediciones. Lite virtualiza filas, días y eventos con los mismos índices del núcleo y nunca renderiza React durante el scroll. Los hooks de render, el zoom, el nivel de detalle y los slots de renderizado React son funciones de Pro, así que las secciones que tratan de ellos solo se aplican a Pro.

Cómo funciona la virtualización

La ventana montada

Solo una ventana alrededor del área visible tiene nodos DOM: el área visible más un margen, ajustada a bloques de un cuarto del área visible, con dos bloques de margen a cada lado. La ventana se recalcula en cada evento de scroll, pero solo cambia cuando se cruza el límite de un bloque. En un fotograma de scroll, el área visible más un bloque se pinta de forma síncrona; el resto de la ventana se pinta de forma progresiva en los fotogramas siguientes, empezando por las filas más cercanas. Las llamadas como control.update() renderizan de forma síncrona y nunca se reparten entre fotogramas.

Filas

Los recursos se aplanan en filas una sola vez (filas del árbol incluidas), y las alturas de fila viven en un índice de sumas de prefijos, así que encontrar las filas de una posición de scroll es una operación logarítmica, no un recorrido por todas las filas. Plegar, desplegar o filtrar reconstruye la lista de filas visibles en una sola pasada. Cuando cambia la altura de una fila, las filas de debajo se desplazan como bloques enteros, en lugar de volver a aplicar estilos a cada celda y cada evento.

Celdas

Las líneas de la cuadrícula y el sombreado de fines de semana o del tiempo no laborable se pintan como un fondo repetido, así que las celdas normales no tienen ningún nodo DOM. Una celda solo tiene nodo cuando está personalizada (con onBeforeCellRender o renderCell) y está dentro de la ventana montada. Cuando alejas el zoom por debajo de 2 px por celda, las celdas personalizadas no se crean y su hook no se llama.

Eventos

Los eventos se indexan por recurso y por tiempo, así que un rango visible se encuentra con una consulta logarítmica. El apilado de solapamientos se calcula a partir de las horas, no de los píxeles, así que el zoom nunca vuelve a apilar las filas. Los nodos de evento salen de un pool y se reutilizan a medida que se mueve la ventana. A tamaños pequeños, el nivel de detalle pasa los eventos de contenido completo a texto, después a bloques simples y, al final, a una barra por fila, y oculta los vínculos cuyos extremos son demasiado pequeños para verse. lod: false desactiva esa adaptación y cuesta más con el zoom alejado.

Sin renders de React durante la interacción

Los fotogramas de scroll, zoom, hover, selección y arrastre los gestiona el motor con geometría en caché y un único planificador de fotogramas que separa las lecturas de layout de las escrituras en el DOM. El contenido React de super-scheduler/react-render es la única excepción, por diseño: primero se pinta el HTML o el texto de respaldo, y el contenido React se publica cuando termina la interacción, en lotes que apuntan a 8 ms. Las funciones opcionales, como la navegación con teclado, los menús y las burbujas, se cargan como chunks separados cuando las configuras o las usas por primera vez, nunca durante un gesto.

Planificación de atraquesUn buque llega doce horas tarde. Mueve su ventana de atraque y lleva con ella el remolcador y las grúas.

Qué cuesta tiempo en una integración

La librería no puede abaratar tus callbacks. Estos son los puntos donde las integraciones reales gastan tiempo.

Hooks de render

HookCuándo se ejecutaLa caché dura hasta
onBeforeEventRenderPara cada evento que necesita el layout, no solo los visibles, porque puede cambiar height, line o hiddenUn cambio en los datos del evento
onBeforeCellRenderPara cada celda dentro de la ventana montada, por encima de 2 px por celdaNuevos resources, control.update() o un cambio en los eventos de la fila cuando el recurso tiene cellsAutoUpdated: true
onBeforeRowHeaderRenderPara las cabeceras de fila montadasUn cambio en la fila o en los datos de su recurso
onBeforeTimeHeaderRenderPara las celdas de cabecera montadasUn cambio en el eje de tiempo (escala, nivel de zoom, fechas)
onEventMoving, onEventResizing, onTimeRangeSelectingEn cada cambio de la sombra durante un gestoNunca se cachea

Pasar una función nueva a un hook de render también vacía su caché. Limita los hooks a búsquedas y construcción de cadenas. Precalcula mapas y conjuntos fuera del hook, crea los formateadores Intl una sola vez a nivel de módulo, no leas nunca el layout (getBoundingClientRect) y no cambies nunca el estado de React dentro de un hook. Durante un arrastre, args.conflicts se calcula al acceder a él por primera vez, así que no lo leas salvo que la regla lo necesite.

Props cuya identidad cambia

El componente React solo reenvía las props cuya identidad ha cambiado desde el último render (Object.is). Cada prop reenviada tiene un coste:

  • Un nuevo array events cuyos objetos difieren de los del control recarga todo el almacén de eventos y vuelve a ejecutar onBeforeEventRender para cada evento. Devolver los mismos objetos ([...args.events] desde onEventsChange) se reconoce como un eco y se omite la recarga.
  • Un nuevo array resources reconstruye las filas e invalida todas las celdas.
  • Una nueva función de hook invalida la caché de ese hook.
  • Los nuevos objetos timeHeaders, zoomLevels, classNames o styles se vuelven a aplicar.

Define las constantes a nivel de módulo, memoiza las props derivadas con useMemo y envuelve en useCallback los handlers que dependen del estado. Una prop que desaparece entre renders vuelve a su valor por defecto, así que mantén estables también las props condicionales.

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}
    />
  )
}

Slots de renderizado React

renderEvent, renderRowHeader y las demás props de render montan contenido React mediante portales dentro de nodos del motor. Funcionan bien para eventos y cabeceras. renderCell monta un slot React por cada celda montada, lo que se acumula rápido en una cuadrícula densa; en cuadrículas grandes, es preferible usar cadenas de onBeforeCellRender y reservar React para las celdas que necesitan interacción. Memoiza las funciones de render, y ajusta renderOptions.retain (elementos desmontados que se conservan; por defecto, el menor entre 2.000 y el doble del número de elementos montados) y renderOptions.sliceMs (objetivo por lote, 8 ms por defecto) solo después de medir. Consulta Slots de renderizado React.

Cambios de datos y tu propio estado

  • control.events.add, update y remove son incrementales y sirven para ediciones sueltas. Para cientos de cambios a la vez, como una importación o un refresco desde el servidor, entrega al scheduler un array nuevo en lugar de llamarlos en un bucle.
  • control.update() sin argumentos es un refresco completo. Pasa solo las opciones que han cambiado.
  • onZoom se ejecuta en cada fotograma de un gesto de zoom. Escribe en el DOM la respuesta visual por fotograma y actualiza el estado de React solo cuando args.phase === 'end'.
  • useScheduler({ track: [...] }) de super-scheduler/hooks publica cuando los cambios se asientan, nunca por fotograma. Sigue solo los temas que muestra cada componente.
  • Los padres plegados del árbol reducen el trabajo de montaje: el layout se calcula para las filas desplegadas.

Lista de comprobación

  • Importa el scheduler en las rutas que lo usan, para que su código quede fuera del resto de tu aplicación (Renderizado en servidor).
  • Mantén estables entre renders events, resources, timeHeaders, zoomLevels, classNames y los hooks.
  • Transforma las filas del servidor en objetos de evento una vez por respuesta, no durante el render.
  • Dale al control su propia copia del array de eventos (useMemo(() => events.slice(), [events])), porque modifica ese array en el sitio con splice.
  • Limita onBeforeEventRender y onBeforeCellRender a búsquedas; activa cellsAutoUpdated solo en las filas cuyas celdas dependen de sus eventos.
  • En cuadrículas densas, prefiere hooks que devuelven cadenas a renderCell.
  • Agrupa los cambios de datos grandes en un único array nuevo.
  • Carga las líneas de tiempo largas por rangos con super-scheduler/ranges en lugar de enviar años de datos.
  • Deja lod activado salvo que necesites un render literal en todos los niveles de zoom.
  • No cambies nunca el estado de React desde callbacks que se ejecutan en cada fotograma.

Medir

La librería se mide con un método reproducible, y el mismo método sirve para tu integración:

  • Builds de producción. Los builds de desarrollo de React y de tu aplicación son más lentos y añaden comprobaciones.
  • Fases separadas. Genera o descarga los datos antes de montar, y después mide el tiempo de montaje con un layout forzado a continuación.
  • Fotogramas, no medias. Registra los percentiles p50, p95 y p99 del tiempo de fotograma mientras desplazas con la rueda sobre la cuadrícula, en diagonal, durante el autoscroll y con varios anchos de zoom. Cuenta los nodos DOM montados y la memoria después de la recolección de basura.
  • Commits de React. Envuelve el scheduler en un <Profiler> y comprueba que el scroll, el zoom y el arrastre no provocan commits. onRender se dispara en los builds de desarrollo; en producción necesita el build de profiling de React.
  • CPU ralentizada. Repite con la CPU ralentizada 4× en las herramientas de rendimiento del navegador, y en los dispositivos que usan tus usuarios.
  • Aísla tus callbacks. Compara sin hook, con un hook vacío y con tu hook para ver lo que cuesta tu código.
  • Varias ejecuciones. Una sola muestra lenta cerca de un umbral es ruido. Compara medianas de varias ejecuciones en una máquina sin otra carga.

super-scheduler/datasets genera los mismos escenarios deterministas que usa la librería, así que puedes reproducir una carga sin tu 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>
    </>
  )
}

Deberías ver que el contador se detiene después del montaje inicial: desplazarte por las 1.000 filas y los dos años del plan no añade ningún commit de React.

Mediciones publicadas

La revisión de rendimiento de la librería del 7 de octubre de 2026 registró estos resultados. Método: Chromium 145 headless con rasterización por software, área visible de 1440 × 900 con una relación de píxeles del dispositivo de 1, la demo de la librería en un build de producción con el build de profiling de React, en un Apple M5 Pro con 24 GiB que ejecutaba otras aplicaciones. Los tiempos de fotograma son intervalos de requestAnimationFrame en una pantalla de unos 120 Hz durante las trazas de scroll del banco de pruebas, así que 8,3 ms es el propio intervalo de fotograma de la pantalla. El montaje de S1 es la mediana de tres montajes; los escenarios más pesados se montaron una vez.

EscenarioDatosMontajeFotograma p50 / p95 / p99Nodos DOMHeap tras GC
S1120 filas, 730 días, unos 6.000 eventos23,2 ms8,3 / 9,1 / 9,3 ms2.50611,2 MiB
S1, CPU 4×Los mismos104,6 ms16,1 / 25,2 / 25,9 ms2.50611,2 MiB
S35.000 filas, 1.500 días, 200.021 eventos291,6 ms8,3 / 9,2 / 9,4 ms2.035205,4 MiB
S3DenseComo S3, sin ninguna noche libre en ninguna habitación, 1.634.510 eventos2.174,8 ms8,3 / 9,3 / 16,8 ms3.7381.589,2 MiB

Todos los escenarios registraron cero renders de React durante el scroll. Estas cifras proceden de una sola máquina en un solo día, con la aplicación de demostración de la propia librería y sus hooks; son observaciones, no garantías para tu integración.

Límites

El DOM sigue siendo pequeño a cualquier tamaño, pero el tiempo de montaje y la memoria crecen con el número total de eventos, porque al construir una cuadrícula el layout se calcula para todas las filas desplegadas. La fila S3Dense de arriba muestra el techo: más de dos segundos de montaje y unos 1,6 GiB de heap para 1,6 millones de eventos. Mucho antes de llegar ahí, carga por rangos y conserva solo las fechas con las que trabaja la gente. Todavía no hay una API de modificación masiva, así que las actualizaciones en directo muy grandes conviene aplicarlas como un único array de eventos nuevo. Muchos vínculos también añaden trabajo de pintado, ya que cada pintado tiene en cuenta todos los vínculos.

Guías relacionadas: Integración con React, Estado controlado, Escalas de tiempo y zoom y Solución de problemas.