Skip to content
Flow-UILive
On this page

Stable widget identities

SwiftUI diffs identity. SDUI widgets need stable ids across refresh so steppers and expansion state do not reset.

Ayush MishraPublished 6 min read

SwiftUI diffs identity. SDUI widgets need stable ids across refresh so steppers and expansion state do not reset. If the same bytes decode to new ids every time, ForEach treats the page as a brand new list. Scroll position jumps. In-flight images restart. Accordion flags and stepper counts, which are keyed by widget id in WidgetStateStore, look like they were wiped even when the store still holds the old keys.

Flow-UI’s fallback is not a random UUID. Widget and section identity, when the backend omits id, is derived from the coding path: prefix@dotted.path. Collisions at page level become id#2. Nested accordion children are not part of that uniqueness pass. Backends should still send real ids for anything interactive or replaceable. The fallback is the floor.

This sits next to lossy decoding and diagnostics (duplicateID is one of the four kinds) and unknown widgets and schema versioning. Resilience is not only “do not throw.” It is “do not reshuffle the tree.”

Why identity is a decode problem#

AnyWidget is Identifiable. Section lists and widget lists render with ForEach. SwiftUI’s diff is identity first, then view updates. A positional index as identity looks stable until pagination inserts a section at the top. A UUID() assigned in init(from:) looks unique until the next decode of the same document produces different UUIDs and SwiftUI throws the tree away.

FlowIdentity.positional uses the coding path the decoder already has. Two decodes of the same bytes, same schema, same array lengths, produce the same string. Example: image_text_card@page.sections.0.widgets.2.

StableIdentity.swiftswift
static func positional(_ prefix: String, in codingPath: [CodingKey]) -> String {
    let path = codingPath
        .map { key in key.intValue.map(String.init) ?? key.stringValue }
        .joined(separator: ".")
    return path.isEmpty ? prefix : "\(prefix)@\(path)"
}

AnyWidget uses the JSON id when present, otherwise FlowIdentity.positional(type, in: container.codingPath). PageModel uses "page" as the prefix if the page id is missing. Sheets are different: a missing sheet id becomes sheet. plus a short UUID because sheet id is presentation identity for .sheet(item:), not list identity. Two inline sheets decoded from the same sheet key would otherwise share a coding path and fail to replace each other. Do not copy that UUID fallback onto widgets.

swift
id = try container.decodeIfPresent(String.self, forKey: .id)
    ?? FlowIdentity.positional(type, in: container.codingPath)

What positional ids do not survive#

Reorder sections on the backend and the coding path of a widget that still has no id changes. It looks new to SwiftUI. Expansion keyed to the old path is orphaned. Send a real id if merchandising reorders.

Pagination appends new sections. Paths in the new document start at sections.0 again in that response. After merge, the host page has more sections. PageStore runs makeWidgetIdentifiersUnique() again on the combined page because two internally consistent responses can still collide. Positional ids from page two can clash with positional ids from page one if both omitted id. Real ids from the backend avoid that class of bug.

Duplicate ids become id#2#

WidgetIdentifierDeduplicator.claim inserts into a set. First claim returns the id unchanged. Later claims try id#2, id#3, and so on until the set accepts the candidate. Diagnostics record duplicateID with a message that mutations targeting the original id are ambiguous.

PageModel.makeWidgetIdentifiersUnique() runs at the end of page decode. It walks:

  • page header widgets
  • each section’s header widget
  • each section’s body widgets
  • page footer widgets

It does not walk nested payloads. Accordion items are not renamed here. Their uniqueness is the parent’s ForEach, as nested widgets and accordion explained.

PageStore.appendNextPage applies the same pass after merging paginated sections, recording into the live diagnostics object.

Mutations care about the id you think you sent#

replace_widget and remove_widget find the first match in header, section header, section body, or footer. If two widgets decoded as cart_row and the second became cart_row#2, an api response that says id: "cart_row" hits the first. The backend thought they were distinct. The diagnostics message is telling you to fix the payload, not to target #2 from the server.

Never send duplicate ids on purpose and rely on the suffix. The suffix is recovery for ForEach, not a public addressing scheme.

WidgetStateStore is keyed by widget id#

Steppers and accordions bind through context.state using context.widgetID. Refresh calls stateStore.reset(), then fetches. Counts and expansion start over because the document is new, not because ids changed. Pagination does not reset the store, which is why stable ids across appended sections matter: a stepper still in the list should keep its count while new sections appear below.

If a refresh returns the same logical row with a different id, you get a new store slot and a visual reset even though reset() already ran. If a refresh returns the same ids, reset() still cleared values, then initial from JSON applies again. That is intended for pull to refresh. It is not intended for a silent identity shuffle without a refresh.

ForEach and images#

Unstable ids also cancel image loads that were in flight for a row that is still visible in the human sense. Users describe this as “the page flickered.” The decoder described it as new identity. Positional fallback exists so omitted ids at least stay still when the bytes stay still.

Backend rules that keep diffs boring#

  1. id is a string you own. Do not recycle an id for a different noun in the same page.
  2. Keep the id stable across refresh for the same logical widget.
  3. Keep the id stable across pagination for widgets that remain on the page.
  4. Nested children need unique ids among siblings, not necessarily against the whole page.
  5. Do not use array index as an id in JSON ("id": "0") if the array can prepend. Use a business key.
  6. After a replace_widget, the replacement widget should carry the same id if it is the same logical row, unless you want state to start fresh.

What the overlay will show#

Duplicate ids appear as duplicateID entries with the rewritten name. Unknown and malformed widgets still have ids (positional or sent). The layout inspector prints type · id. If you see image_text_card@page.sections.0.widgets.4 in a production screenshot, the backend omitted id on a row you probably care about.

Tests that catch shuffle bugs#

Decode the same fixture twice and compare AnyWidget.id values in order. They must match. Decode a fixture that omits ids, then decode it again with one extra section prepended, and expect positional ids to change. That is the demonstration you show the backend team when they ask why merchandising reorder “resets the stepper.”

Do not test identity by printing UUID. Widgets do not use UUID fallbacks. If a test starts generating random ids, it is not going through AnyWidget’s decoder. Sheets may use a short UUID when id is omitted, and only because .sheet(item:) needs a new presentation identity. Keep that exception in sheet tests, not in page tests.

Where this sits#

Resilience already says identity is part of resilience. This article is the exact fallback format (prefix@codingPath), the suffix rule (id#2), the nested exception, and the sheet UUID exception you must not copy onto widgets. How to implement SDUI on iOS called stable ids a checklist item. Here is what the source actually does.

SwiftUI diffs identity. Give it identity that survives the next decode.

The Flow-UI mark: two nodes joined through a filled centre

Path-based fallbacks when id is missing. Suffixes when it collides. Real ids for anything stateful.

More on the blog.

Read the source

Flow-UI is MIT licensed. The schema, renderer and starter widgets live on GitHub.

GitHub

Get started with Flow-UI

Install the Swift package, register a widget, and render a page from JSON.

Get started