Views

The markup is the designer's

A screen does not contain HTML. It clones what the designer already wrote:

Screen >> rowOf: o =
  (Html clone: '#lines tr' fill: {
    '.c-id'       -> o id.
    '.c-customer' -> o customer.
    '.c-total'    -> (self yen: o total) })
      key: o id.

#lines tr is a sample row the designer left in the page so they could see the table. There is no second copy of that markup in another language, and nothing to re-translate when a column is added. If the class name goes away, lpsc build stops — the module declares which page it belongs to, and the selectors are checked against that file.

For a screen with hundreds of fields, the fill does not have to be written one line per field. The keys of the JSON and the class names in the page can be joined by a rule:

Screen >> view = self reply: (
  data keys inject: (Html clone: '#card') into: [:node :k |
    node at: ('.f-' , k) put: ((data at: k) asStr orElse: [(data at: k) asText]) ]).

Three lines, whether the record has six fields or three hundred.

You do not write what to redraw

There is nowhere to say what should be redrawn. No dependency array, no memoisation, no subscription to declare. Nothing to write, and so nothing to forget.

Why none of it is needed

A handler answers the next state. Whether the state moved is therefore a comparison between what came back and what was there.

Screen >> search: text = self with: { query: text }.   "moved — draw"
Screen >> ping         = self.                         "did not — do not"

The comparison is identity, not a walk over the fields. Values never mutate, so a state built with with: is necessarily a different one, and self is necessarily the same one.

That decision is made in one place, and it is not in your screen.

Three things to know, and that is the list

Answer self when nothing changed. Nothing is drawn.

A child actor is its own region. A parent's re-render does not descend into it, because what the parent holds is a reference and the reference did not change. Make the rows of a list child actors and the parent's re-render never reaches them.

Give list rows a key:. The node moves instead of being rebuilt, so an input inside the row survives.

That is the whole of it. There is no question of whether a memo was applied, what went into a dependency array, or whether a subscription was released.

It shows up in practice

While writing a game, the score label was told the score on every frame. The label answered a new state each time, so the DOM was written sixty times a second to say the same thing. The worst frame gap had grown to 48.2ms.

Making the label answer self when the text had not changed brought it back to 18.8ms. The fix was one line — not a dependency to declare, just saying that the state had not moved.

What is absent

React asks for React.memo, useMemo, useCallback and dependency arrays. Forget one and you get either wasted renders or a stale value on screen, and both wait until run time to show themselves.

None of that exists here. Not one line is written for the sake of redraw performance.

Events are messages

A handler is named, not captured:

(row on: #click send: #select: with: o id)

This is not a style choice. A VNode may be asked for across an actor boundary and a block cannot cross one, so the handler has to be a name. What follows is that events are checked like every other message: an unknown handler is a compile error, and what the event carries is typed. A DOM event is not Sendable, so it is read out as data — e key, e value, e scrollTop.

For an element the actor does not own — the designer's own button, elsewhere in the page — me listen: '#back' on: #click send: #goBack hears it without owning it.

Give a row a key

When you build a row, hand it whatever identifies that row — an order number, an employee number.

(Html clone: '#lines tr' fill: { ... }) key: o id

With a key, sorting or filtering moves the DOM node instead of rebuilding it. If the row holds an input, the half-typed text and the focus are still there afterwards.

Without one the display is still correct: children are matched by position and their contents overwritten. If nothing inside the row holds state, that costs you nothing.

Two rows with the same key is an error. It is not quietly patched, because patching it drops a row and says nothing.

Past a few thousand rows, draw what is on screen

A render costs in proportion to the rows on screen. A few hundred is fine drawn whole. A few thousand starts to hurt.

Then you draw only the rows in view. That is a way of writing a screen, not a feature of the language. Listening to the frame's scroll gives you scrollTop and height; the row height is a constant the CSS already fixed, so dividing tells you which rows are needed. The height of what is not drawn goes into a padding row above and below, which is what keeps the scrollbar the right length.

Ten thousand rows is the worked example: two dozen rows drawn, 4.9ms a render.

A frame is a message too

requestAnimationFrame is four lines of Extern, and its callback only tells the actor. Everything else is the same as a business screen, which is why a game is a screen: measured in a browser, 60 frames a second with the actor redrawing on every one of them.

Drawing to a canvas works the same way, except that view is not used at all — a canvas keeps what was drawn on it, so the actor clears and redraws each frame.