ΛↃ LAMBDACOMBINE

Symbolic Systems Infrastructure

06 · Fat Morph

FAT MORPH · IDIOMORPH · DATA-IGNORE-MORPH · MODE OUTER
Duga 3
Duga-3 (source: Bert Kaufmann)

Instead of targeting individual elements, you can send the entire content region every tick and let Datastar's idiomorph merge apply only the differences to the DOM. This is the fat morph pattern.

Idiomorph - used by Datastar - preserves DOM state for elements that have not changed: focus, scroll position, form input values. The key insight is that sending more HTML is not usually more expensive: text compresses well, browsers are extremely goo at parsing HTML (obvously), which means that instead of having to keep track of different updates, you can just think of the page and send whatever is the current state of the entire page (up to the `html` element): Datastar will pick everything and only replace the parts that need an update.

mode :outer

:mode :outer replaces the target element itself (not just its children). The selector (in this case, it represents a div that)identifies the element to morph; the new HTML replaces it entirely, with idiomorph minimising the DOM mutations:

(defun render-content ()
  (spinneret:with-html-string
    (:div :id "content"
          (:p "Time: " (:strong (current-time-string)))
          (:p "Count: " (:strong *counter*)))))

(d*:with-sse (gen hunchentoot:*request*)
  (loop
    (d*:patch-elements gen (render-content)
                       :selector "#content"
                       :mode :outer)
    (sleep 1)))

data-ignore-morph

data-ignore-morph on an element tells idiomorph to leave it entirely alone. Use this for contenteditable regions, interactive widgets, or anything where the DOM state matters more than the server's version.

(:div :data-ignore-morph "true"
      (:p :contenteditable "true" "Edit me freely."))

Fat morph is also resilient to SSE reconnects. After a reconnect, the next event contains the full current state, so no divergence is possible. With fine-grained patching, a missed event leaves the UI stale.

Live Demo

The content region below is replaced every second. The update and click counters increment server-side. The editable paragraph is excluded from morphing: type something and watch it survive every update.

Time: 03:27:00

Updates: 19

Clicks: 0


The editable paragraph below is protected with data-ignore-morph. Type here and it will not be reset by morphing.

Edit me freely.

Full Source (standalone Hunchentoot)

;;;; -*- Mode: LISP; fill-column: 80; coding: utf-8 -*-

;;;; FAT-MORPH.LISP --- Fat morph pattern demo
;;;;
;;;; Copyright (C) 2025, 2026 Frederico Muñoz / ΛↃ lambda combine
;;;;
;;;; This file is part of datastar-cl, the Common Lisp SDK for Datastar
;;;;
;;;; License: MIT

;;; Instead of fine-grained element selection, sends the entire content area's
;;; HTML every tick. Datastar's idiomorph merge applies only actual differences
;;; to the DOM, preserving unchanged elements. Also resilient to SSE
;;; interruptions: after reconnect the next event contains the complete state.

(ql:quickload '(:hunchentoot :spinneret :datastar-cl/hunchentoot))


(defpackage #:fat-morph
  (:use #:cl #:hunchentoot)
  (:local-nicknames (:sp :spinneret) (:d* :datastar-cl) (:ht :hunchentoot)))
(in-package #:fat-morph)

(defun read-file-to-string (pathname)
  (with-open-file (stream pathname :direction :input)
    (let ((contents (make-string (file-length stream))))
      (read-sequence contents stream)
      contents)))

(defparameter *updates* 0)
(defparameter *clicks* 0)

(defun render-content ()
  "Full HTML of the morphed content region -- the 'fat' payload."
  (incf *updates*)
  (multiple-value-bind (s m h) (decode-universal-time (get-universal-time))
    (sp:with-html-string
      (:div :id "content" :style "border: 1px dotted #faa0a0; padding: 1rem;"
             (:button :|data-on:click| (d*:sse-post "/click") "Click me")            
            (:p "Time: " (:strong (format nil "~2,'0d:~2,'0d:~2,'0d" h m s)))
            (:p "Updates counter: " (:strong *updates*))
            (:p "Button clicks: " (:strong *clicks*))
            (:hr)
            (:p "Below is a paragraph with " (:code "data-ignore-morph")
                ".  It is never touched by morphing, so you can edit it freely.")
            (:div :data-ignore-morph "true"
                  (:p :contenteditable "true"
                      :style "border:1px solid #ccc;padding:0.25em 0.5em;"
                      "Edit me -- morphing will not reset my content!"))))))

(ht:define-easy-handler (index :uri "/") ()
  (setf (hunchentoot:content-type*) "text/html")
  (sp:with-html-string
    (:doctype)
    (:html
     (:head (:script :type "module" :src (d*:datastar-url))
            ;;(:style (:raw (read-file-to-string "style.css"))))
            )
      (:body :data-init (d*:sse-get "/sse")
             (:h1 "Fat Morph")
             (:p "The entire #content div (inside the red border) is replaced every second.  "
                 "Only actual changes are applied to the DOM.")
             (:p "The time and counter change each tick: everything else morphs without DOM churn. The button clicks will be updated as part of the backend update loop (1s).")
             (:raw (render-content))))))

(ht:define-easy-handler (sse :uri "/sse") ()
  (d*:with-sse (gen ht:*request*)
    (loop
      (d*:patch-elements gen (render-content) :selector "#content" :mode :outer)
      (sleep 1))))

(ht:define-easy-handler (click :uri "/click") ()
  (d*:with-sse (gen ht:*request*)
    (incf *clicks*)))

(ht:start (make-instance 'ht:easy-acceptor :port 8989))
(format t "~&Server started on http://localhost:8989~%")

(defroute guide-morph (:get :text/html))