Skip to content

re-frame.freehand.collection

Pre-alpha — day8/re-frame2-freehand is not published

Freehand ships inside the re-frame2 monorepo and is not published to Clojars, and there is no date at which it will be. You resolve it with :local/root from a checkout — see Install. The public surface is deliberately still open: verbs can change while we learn from real apps.

re-frame.freehand.collection is the fixed-size virtual collection (EP-0036; artefact day8/re-frame2-freehand, conventionally aliased coll) — and the interesting question about it is not what it renders but what it does not.

A list of ten thousand rows is the ordinary application shape that breaks a naive substrate three separate ways: it mounts ten thousand boundaries, it subscribes ten thousand times, and — once somebody adds virtualization by hand — it grows a second state system for the scroll position, loses keyboard focus every time the focused row leaves the window, and lies to a screen reader about how many rows there are. None of those is inherent, and this is the surface that says so.

Everything on this page is advanced tier. Fixed-size virtualization is opt-in scalability machinery an ordinary list never needs: reach for it when the problem appears.

(:require [re-frame.freehand :as v]
          [re-frame.freehand.collection :as coll])

[coll/virtual-list
 {:id              "inbox"
  :row-keys        (v/sub [:inbox/ids])
  :row-extent      32
  :viewport-extent 640
  :scroll-offset   (v/sub [:inbox/scroll])
  :active-index    (v/sub [:inbox/active])
  :on-scroll       [:inbox/scrolled]
  :on-key          [:inbox/key-pressed]
  :on-activate     [:inbox/opened]
  :row             (v/render-fn [id _index _total] [inbox-row {:id id}])}]

and inbox-row is an ordinary declared view that reads its own item:

(v/defview inbox-row
  {:props [:map [:id :some]]}
  [{:keys [id]}]
  (let [m (v/sub [:inbox/message id])]
    [:span (:subject m)]))

That is the whole port of a re-com v-box of rows, and it is short because the control asks for values and emits intent, exactly like every other props-only view. It owns no record, so it needs no :control address, no generation and no release.

Two layers, because virtualization is not a widget

virtual-collection is the engine and it is semantic-neutral: it owns the scroll host, the full-height canvas, the keyed positioned row shells and the window arithmetic, and it names no ARIA role at all. Its row slot receives the row's key, its absolute index and the collection's total — the three facts virtualization makes hard to state honestly — and what those facts are spelled as is yours.

virtual-list is the listbox built on it: role="listbox", options, selection, aria-activedescendant, and nothing else. It contains no second window arithmetic and no second scroll host.

The split is arithmetic against evidence rather than anticipation. W3C's own listbox pattern sends a collection of interactive rows to the grid pattern, whose cell and focus contract is materially different — so a virtual list hard-coded to option cannot host an editing grid without either a second engine or dishonest semantics. Mature virtualizers split at exactly this line; so does this.

What is deliberately not here: variable row heights, a columns model, sorting, a grid framework, and any :semantics option pretending to implement whole ARIA interaction patterns from a keyword.

The five things it refuses

A second state system. The scroll offset the control renders from is your :scroll-offset prop and nothing else. :on-scroll carries ::v/scroll-top, so the host's scrollTop reaches an ordinary event and lands in app-db, where a tool can read it, an epoch can carry it and a snapshot can restore it. There is no host slot, no ref, no local atom and no controller record.

Row content in its own props. The control never receives an item. It receives :row-keys — the ordered vector of identities — and a :row render-fn whose body mounts your declared row view. So a row's content is read by the row's own boundary and an edit to one row invalidates one boundary. Handing the control a vector of item maps would make every keystroke anywhere in the collection publish a new vector and re-render the whole visible window; that spelling is not discouraged here, there is nowhere to put it.

Moving DOM focus to a row. The viewport is the focus holder and the active row is named by aria-activedescendant. Roving focus is the pattern virtualization breaks: the focused element is unmounted the moment it scrolls out of the window, focus falls to <body>, and the next keystroke goes nowhere. Here the element that holds focus is the one element that is never windowed.

Lying about the size. Every rendered row carries aria-posinset for its true absolute position and aria-setsize for the true total, and the canvas carries the true full extent. The DOM contains the window; the accessibility tree and the scrollbar state the collection.

Measuring anything. Rows are a fixed extent you supply. There is no measurement pass, no ResizeObserver, no dynamic window and no cache of measured heights — which is why window is arithmetic rather than a scheduler.

The controls

virtual-collection

[coll/virtual-collection
 {:row-keys        (v/sub [:grid/ids])
  :row-extent      28
  :viewport-extent 560
  :scroll-offset   (v/sub [:grid/scroll])
  :on-scroll       [:grid/scrolled]
  :attrs           {:role "grid" :aria-rowcount (v/sub [:grid/total])}
  :row             (v/render-fn [id index total] [grid-row {:id id :index index :total total}])}]

The virtualization engine: a scroll host, a canvas of the collection's full height, and the keyed positioned shells of the visible window.

prop
:row-keys required — the ordered vector of stable item identities
:row-extent required — the fixed row height, in pixels
:viewport-extent required — the scroll host's height, in pixels
:scroll-offset required — the controlled scroll position
:row required — a v/render-fn of [row-key index total]
:on-scroll required — the scroll intent; ::v/scroll-top is appended
:overscan optional — rows rendered beyond each edge
:attrs optional — attributes for the viewport, through v/spread-safe

:scroll-offset is controlled, in both directions. It is not a cache of what the host happens to hold. The render picks the window from it, and the engine lands it on the live viewport before paint whenever the two disagree. Each direction alone is a bug: render alone leaves a freshly mounted viewport at scrollTop 0 under rows painted at 3072px, and the DOM alone leaves the offset somewhere no epoch, snapshot or tool can see. Together they make the offset ordinary application state a restore-epoch can move on a mounted viewport. There is no opt-out — the un-reconciled mode is the blank-viewport bug, not a lighter configuration of the same idea.

Why it names no role. Virtualization mechanics and widget semantics are different layers, and a control fusing them can only ever serve one widget. So the engine emits mechanics and nothing else: data-part names, the positioning, the canvas extent, and two evidence attributes (data-window-first, data-window-count) that state the window the render decided. Three parts — viewport, canvas, row-shell — and the shell is role="presentation" because it is geometry rather than meaning.

:attrs is a map rather than the loose leftovers of the props, and that is the grammar's doing: a render-slot callback is legal only as a literal call-site prop value, so a wrapper folding its own props into a merge could not also hand this one a :row.

virtual-list

[coll/virtual-list
 {:id              "inbox"
  :row-keys        (v/sub [:inbox/ids])
  :row-extent      32
  :viewport-extent 640
  :scroll-offset   (v/sub [:inbox/scroll])
  :active-index    (v/sub [:inbox/active])
  :on-scroll       [:inbox/scrolled]
  :on-key          [:inbox/key-pressed]
  :on-activate     [:inbox/opened]
  :row             (v/render-fn [id _index _total] [inbox-row {:id id}])}]

A fixed-extent virtual listboxvirtual-collection wearing role="listbox", and nothing more than that.

prop
:id required — the DOM id the row addresses derive from
:row-keys required — the ordered vector of stable item identities
:row-extent required — the fixed row height, in pixels
:viewport-extent required — the scroll host's height, in pixels
:scroll-offset required — your scroll position
:row required — a v/render-fn of [row-key index total]
:on-scroll required — the scroll intent; ::v/scroll-top is appended
:overscan optional — rows rendered beyond each edge
:active-index optional — the active row, by position
:on-key optional — the key intent; ::v/key is appended
:on-activate optional — the row intent; the row's key is appended
anything else forwarded to the scroll host through v/spread-safe

The emphasis is on how little it is. It runs no window arithmetic, owns no scroll host, positions nothing and measures nothing — all of that is the engine's. What it adds is exactly the listbox pattern: role="listbox" and tabindex 0 on the engine's viewport, option rows carrying the accessible position and the selection, aria-activedescendant, and a key intent.

Four semantic parts under one data-component scope and no others: viewport, canvas and row-shell are the engine's, row is this layer's option. Everything inside a row is yours — the control emits no text, no cell, no divider and no chrome, because a list owning its row markup would be un-adaptable exactly where every design system differs.

Reading it back. The DOM holds the window; aria-setsize and the canvas height hold the collection. (count (find-all tree #(= "row" (:data-part (attrs %))))) is therefore the deterministic count of rendered rows — a fact a test asserts by equality, with no timing and no instrumentation — and it is equal to (:count (window …)) by construction.

The arithmetic

Both of these are pure functions of scalars, which is where the correctness lives. Neither schedules, measures, throttles, observes a resize or owns a frame.

window

(coll/window {:item-count 10000 :row-extent 32
              :viewport-extent 640 :scroll-offset 0 :overscan 4})
;=> {:first 0 :count 24 :extent 320000}

The visible window over a fixed-extent collection — a pure function of five integers, answering three.

in
:item-count how many items the collection holds
:row-extent the fixed height of one row, in pixels
:viewport-extent the height of the scroll host, in pixels
:scroll-offset how far the host is scrolled
:overscan rows to render beyond each edge; optional, default 0
out
:first the absolute index of the first rendered row
:count how many rows are rendered
:extent the full scrollable height, item-count * row-extent

:count does not depend on :item-count. It is bounded above by ceil(viewport/extent) + 1 + 2*overscan for every collection size, which is the whole cost claim of the control stated as arithmetic: ten items and ten million items render the same number of rows, so the work per frame is a property of the viewport rather than of the data.

Total over nonsense. A negative or absent count, extent, viewport or offset is read as its floor rather than throwing, an offset beyond the end is clamped to the last full screen, and an empty collection or a zero-height viewport answers :count 0. A window is a fact about how much fits, and there is no arrangement of integers for which the honest answer is an exception.

reveal-offset

(coll/reveal-offset {:item-count 10000 :row-extent 32
                     :viewport-extent 640 :scroll-offset 0} 25)
;=> 192

The smallest scroll offset that puts a row wholly inside the viewport — the arithmetic behind "the keyboard moved past the edge, so the list scrolled".

A row already whole on screen answers the current offset unchanged, which is what stops arrow-key navigation inside the window from jerking the list. A row above the window is brought to the top edge, a row below it to the bottom edge, and the answer is clamped into the scrollable range like any other offset.

It is a function rather than a behaviour of the control because the decision is yours: which key moves the active row, whether it wraps, and whether moving it scrolls at all are policy, and a control that owned them would be a keyboard framework. Your handler calls this against committed state and returns the new offset in the same event as the new active index, so the two settle as one epoch:

(rf/reg-event :inbox/key-pressed
  (fn [{:keys [db]} [_ key]]
    (let [geom  {:item-count (count (:inbox/ids db)) :row-extent 32
                 :viewport-extent 640 :scroll-offset (:inbox/scroll db)}
          next  (case key "ArrowDown" (inc (:inbox/active db))
                          "ArrowUp"   (dec (:inbox/active db))
                          (:inbox/active db))]
      {:db (assoc db :inbox/active  next
                     :inbox/scroll  (coll/reveal-offset geom next))})))

What is deliberately not here

Four names are the whole namespace. Three more things exist inside it and are private, because each has exactly one consumer and that consumer shares the namespace:

  • the listbox's own option row, which virtual-list mounts through :row and nothing else reaches;
  • the positional DOM address of a rendered row. A published builder would be a published id string; the supported contract is the rendered relationship — the listbox owns aria-activedescendant, and each mounted option owns its id, aria-posinset and aria-setsize. That is how a test or a stylesheet finds a row;
  • the guarded :layout behavior that writes :scroll-offset onto the viewport before paint, and only when the node disagrees, so an ordinary user scroll (where app-db is merely trailing the DOM) moves no host state at all. virtual-collection attaches it; nothing else does.