Hydration utilities

Activate Rho's build-time "static HTML with marker classes" into interactive components in the browser.


What needs hydration

DSL Needs hydration
interact (slider / input / select / toggle / button / template / computed / timer / vega / svg) ✅
tabs (click to switch) ✅
stepper (Prev/Next) ✅
modal (click to expand) ✅
───────────────────────────────
Callout ❌ Static HTML
Layout grid + cards ❌ CSS Grid
Annotate ❌ CSS hover
Timeline ❌ CSS layout
STL syntax highlight ❌ highlight.js auto

hydrateAll(container) — one-shot activate

The most-used API. Scans all Rho marker classes in the container and takes them over in turn.

import { hydrateAll } from '@rho/md';

const container = document.getElementById('root');
container.innerHTML = await renderRho(markdown);
hydrateAll(container);

Idempotent — safe to call multiple times on the same container. Each hydrated block is marked with data-rho-hydrated="true"; subsequent calls skip.

Equivalent to:

renderInteractBlocks(container);
renderTabsBlocks(container);
renderStepperBlocks(container);
renderModalBlocks(container);

Individual hydrate

For fine-grained control (lazy load / performance tuning / partial takeover):

import {
  renderInteractBlocks,
  renderTabsBlocks,
  renderStepperBlocks,
  renderModalBlocks,
} from '@rho/md';

// Hydrate tabs only (structural); defer interact (heavy)
renderTabsBlocks(container);

// Later, on tab switch, hydrate the active panel's interact
container.querySelector('.rho-tab-btn').addEventListener('click', () => {
  const activePanel = container.querySelector('.rho-tab-panel:not([hidden])');
  renderInteractBlocks(activePanel);
});

Hydration timing

Pattern 1: hydrate immediately after render (default)

container.innerHTML = html;
hydrateAll(container);

Best for: small content where everything's visible right away.

Pattern 2: hydrate when scrolled into view (lazy)

const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      hydrateAll(entry.target);
      observer.unobserve(entry.target);
    }
  });
});

container.querySelectorAll('.rho-interact, .rho-tabs, .rho-stepper').forEach((el) => {
  observer.observe(el);
});

Best for: long documents with many charts — avoid first-paint lag.

Pattern 3: hydrate only on user trigger

container.innerHTML = html;
const button = document.getElementById('activate-demos');
button.addEventListener('click', () => {
  hydrateAll(container);
  button.remove();
});

Best for: extreme performance optimization scenarios; explicitly tells users "click to enable demos."


Hydration options

Some hydrate functions accept options (note: option types may evolve across versions):

import { hydrateAll } from '@rho/md';
import type { HydrationOptions } from '@rho/md';

const options: HydrationOptions = {
  // Callback after each block hydrates
  onBlockHydrated: (blockEl: HTMLElement, type: string) => {
    console.log(`Hydrated ${type}`);
  },

  // Fallback UI when hydration fails
  errorBoundary: (blockEl: HTMLElement, error: Error) => {
    blockEl.innerHTML = `<p style="color: red">Failed to hydrate: ${error.message}</p>`;
  },

  // Skip specific marker classes (default: hydrate all)
  skip: ['rho-modal'],
};

hydrateAll(container, options);

Re-hydrate (content changed)

When markdown source changes → re-process → re-inject into container:

async function update(newMarkdown: string) {
  container.innerHTML = await renderRho(newMarkdown);
  hydrateAll(container);
}

Why hydrateAll is still safe: the previous hydrated DOM was replaced by innerHTML = ...; the new DOM has no data-rho-hydrated and gets hydrated normally.

⚠️ When you replace the DOM, previous interact state (slider position / current values in namespace computeds) is lost — hydrate is "static HTML → live," not "live → live." To preserve state, use a framework component approach (manage state in React/Vue).


Hydration order with nesting

hydrateAll defaults to outer-first. Example:

<div class="rho-tabs">                ← 1. Hydrate tabs first
  <div class="rho-tab-panel">
    <div class="rho-interact">         ← 2. Then hydrate interact
      <div class="rho-modal-trigger">  ← 3. Modal last
    </div>
  </div>
</div>

Usually you don't care — hydrate is declarative; the result is consistent. But knowing this helps when debugging hydrate-order issues.


Server-side rendering (SSR) + hydration

// === SSR (Node.js) ===
const html = String(await processor.process(markdown));
const fullPage = `
<!DOCTYPE html>
<html>
<head>
  <link rel="stylesheet" href="/@rho/md/css/stl-md.css">
</head>
<body>
  <div id="root" class="markdown-body">${html}</div>
  <script type="module" src="/client.js"></script>
</body>
</html>
`;
return fullPage;
// === client.js (browser) ===
import { hydrateAll } from '@rho/md';

hydrateAll(document.getElementById('root')!);

Key: SSR only emits HTML (with marker classes); hydrate must run in the browser.


React / Vue / Svelte integration

React (useEffect)

import { useEffect, useRef } from 'react';
import { hydrateAll } from '@rho/md';

function RhoMarkdown({ source }: { source: string }) {
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!ref.current) return;
    (async () => {
      ref.current!.innerHTML = await renderRho(source);
      hydrateAll(ref.current!);
    })();
  }, [source]);

  return <div ref={ref} className="markdown-body" />;
}

Vue 3

<script setup lang="ts">
import { ref, onMounted, watch } from 'vue';
import { hydrateAll } from '@rho/md';

const props = defineProps<{ source: string }>();
const root = ref<HTMLElement>();

const update = async () => {
  if (!root.value) return;
  root.value.innerHTML = await renderRho(props.source);
  hydrateAll(root.value);
};

onMounted(update);
watch(() => props.source, update);
</script>

<template>
  <div ref="root" class="markdown-body" />
</template>

Svelte

<script lang="ts">
  import { onMount } from 'svelte';
  import { hydrateAll } from '@rho/md';

  export let source: string;
  let root: HTMLDivElement;

  onMount(async () => {
    root.innerHTML = await renderRho(source);
    hydrateAll(root);
  });
</script>

<div bind:this={root} class="markdown-body" />

Performance tips

  • One page hydrating 20+ interact blocks → consider lazy hydrate
  • First paint, only hydrate visible blocks → hydrate the rest on scroll
  • Big charts (vega-lite) lag first paint → let users click to trigger hydration
  • Repeated namespace-shared interact blocks → reactive updates compute once

See also