Skip to main content

Patches: changing part of a page

A trigger that answers with a UiPage replaces the screen. A trigger that answers with a UiPatch changes part of it and leaves the rest alone — the DOM, the scroll position, the cursor in a half-typed field.

A patch is a list of operations, applied in order, each naming a node by id:

UiPatch.of()
.patch(UiPatch.Operation.replace("product-table", newTable))
.patch(UiPatch.Operation.hide("empty-state"));
{ "patches": [
{ "op": "REPLACE", "targetId": "product-table", "node": { "…": "…" } },
{ "op": "MERGE", "targetId": "empty-state", "attributes": { "display": "HIDDEN" } }
] }

The five operations​

OpCarriesDoes
REPLACEa whole nodeSwaps the target for it.
MERGEan attributes mapChanges the fields it names, keeps the rest.
APPENDa whole nodeAdds it inside the target.
REMOVEnothingTakes the target off the page.
CLEARnothingEmpties the target, keeps the container.

All five work the same in all three renderers — browser SPA, JavaFX desktop, and (for the ones that make sense without a client) server-side rendering.

MERGE: change what you name​

REPLACE needs the whole node. To grey out one button, the server rebuilds and resends the subtree it sits in, and the client throws away the one it had.

MERGE names the fields that changed:

UiPatch.of().patch(UiPatch.Operation.merge("save", Map.of(
"enabled", false,
"label", "Saving…")));

The button's id, icon, confirmation prompt and onClick trigger are never mentioned, so none of them travels — and none of them has to be rebuilt correctly on the server to survive the round-trip. That is the real argument. The byte count is a side effect; not having to know the parts you are not changing is the point.

What MERGE is not

It is not a compression trick. For a small node the saving is unimpressive. It earns its place when the node you are patching is expensive to rebuild, or when the server does not actually know what the rest of it looked like.

What the user entered stays​

The client re-renders a merged node from the model it kept when it last drew it, and that model holds the value the server sent then — not what the user has typed, ticked or selected since. So before re-rendering, the event bus reads the current input under the target and keeps it. The typical case works without echoing anything back:

// The user filled in the form; the server only has something to say about it.
UiPatch.of()
.patch(UiPatch.Operation.merge("signup", Map.of("formError", "Please fix the email.")))
.patch(UiPatch.Operation.merge("email", Map.of("validationError", "Not a company address.")));

Every field keeps what the user entered. The server's value wins only where the merge says so: a value in the attributes of a merged field, or a part of the tree it replaces — merging new fields into a form renders those as sent. Read-only fields always show the model. REPLACE sends the whole node and is rendered exactly as sent.

Clearing a field​

An explicit null clears a field rather than being ignored:

UiPatch.Operation.merge("badge", Collections.singletonMap("icon", null));

Being able to set a state but never clear it would be half a feature. Note Collections.singletonMap rather than Map.of — Map.of rejects null values.

It is shallow​

Naming a field replaces it whole. Merging items swaps the entire list; it does not reconcile entries. Deep-merging arrays is a much bigger promise than this operation makes, and it would need a rule for identity that the model does not carry.

Visibility: hide() and show()​

The commonest merge of all has its own shorthand:

UiPatch.Operation.hide("filters"); // out of the layout
UiPatch.Operation.show("filters"); // back again

Both are merges of the single display attribute. There are two ways to hide, and the difference matters:

WebJavaFXBehaves like
Display.HIDDEN.sui-hidden → display: nonenot visible, not managedThe node is gone; everything below moves up.
Display.BLANK.sui-blank → visibility: hiddennot visible, still managedThe node is invisible; its space stays.
UiPatch.Operation.hide("toolbar", UiNode.Display.BLANK); // keeps its space

Reach for BLANK when something around the node would jump — a toolbar that appears on hover, a spinner that swaps with a value. Reach for HIDDEN when the node genuinely should not be taking up room.

On the node itself​

The same state is a property of any node, so a page can arrive with something already hidden:

UiText.of("warning", "Disk almost full").hidden();
UiText.of("warning", "Disk almost full").blank();
UiText.of("warning", "Disk almost full").visible(); // the default
display is the source of truth

The renderers fold display into a CSS class (sui-hidden / sui-blank) so a plain stylesheet can act on it. Do not set those classes yourself through cssClass — setCssClass strips them precisely so the two cannot disagree. Set display, and let the class be derived.

Where a merge finds the rest of the node​

A merge only says what changed, so the client has to already know the rest. Where it knows it from depends on how the page was delivered.

The renderer drew the tree, so it remembers what every id was built from. A merge works immediately, on any node on the page.

The index is refilled by mount() — a new page means the old ids no longer mean anything — and an id is forgotten when a REMOVE takes it off the page.

If a merge names a target the client has no model for, it is reported rather than silently ignored — a warning in the browser console, the error handler on JavaFX. A merge that quietly does nothing is worse than one that says why.

Merging inside a table​

Rows and columns are nodes with ids, so they can be merged like anything else:

UiPatch.Operation.merge("row-42", Map.of("data", updatedRow));

A table keeps its own model, and a merge on a row goes through it: the table re-renders so header, cells, cellTemplates and selection state stay consistent. A lone <tr> swap could not do that.

Inside a list​

A list's rows are nodes of type item, and so are the buttons in its header and on each row — every one of them has an id, and a patch can name any of them. What mindconnect's migrations screen sends after "Apply", for instance:

UiPatch.of()
.patch(Operation.remove("agent:title-generator")) // one item, gone
.patch(Operation.merge("migration-group-agent-sum", Map.of("text", "Agents (3)"))) // the group's counter
.patch(Operation.merge("apply-all", Map.of("label", "Apply 3 migrations"))); // the header button

The counter is the item's collapseSummaryId: that id names a text node inside the <summary>, so it takes a REPLACE with a UiText or a MERGE of its text. A button takes a MERGE of label, enabled, confirm or loading wherever it stands — a list's header, an item, a form's footer, a table's toolbar, a field's trailing slot. The tree copy behind bus.snapshot() follows every one of these, so what the screen shows and what a snapshot says stay the same thing.

Compatibility​

MERGE arrived after REPLACE, APPEND, REMOVE and CLEAR. A client older than the operation will not understand it — if you serve a mix of versions, keep to REPLACE until the floor has moved.

Try it​

mc-sui-merge-demo in the repository is a page built to be poked at: hide and show a panel, toggle a button's own label and style, and read what each click actually put on the wire beside the REPLACE that would have done the same thing.

mvn -f demo/mc-sui-merge-demo/pom.xml spring-boot:run # http://localhost:9093