Skip to content
Flow-UILive
On this page

Unknown widgets and schema versioning

Old binaries will see new type strings. Version the contract additively. Unknown widgets must fallback. Optional: host sends a capability header.

Ayush MishraPublished 6 min read

Old binaries will see new type strings. Version the contract additively. Unknown widgets must fall back. Optional: the host sends a capability header. Flow-UI does not ship a schema version field on the envelope. If you need the backend to know what this binary can render, that header is your convention, not a package feature.

This is the production half of lossy decoding and diagnostics. That article named the four diagnostic kinds. This one is the policy you ship: what a user sees when type is unknown, how the JSON contract grows, and how capability headers stay on the host side of PageLoader.

Mobile Native Foundation threads keep returning to the same constraint: one backend response, many binaries, some of them months old. Additive JSON and unknown-type fallback are how you avoid coordinating an App Store release with every promo. Feature flags are a different tool; they toggle code you already shipped. See feature flags vs SDUI if you are mixing the two.

UnknownWidgetPolicy is debug versus release#

The registry holds unknownPolicy. The initializer default is .placeholder in DEBUG and .skip otherwise. You may pass an explicit value. There is no third built-in mode that crashes, and you should not add one in the host.

WidgetRegistry.swiftswift
public init(unknownPolicy: UnknownWidgetPolicy? = nil) {
    #if DEBUG
    self.unknownPolicy = unknownPolicy ?? .placeholder
    #else
    self.unknownPolicy = unknownPolicy ?? .skip
    #endif
}

.placeholder renders a labelled block: unknown type versus malformed payload, orange versus red. .skip renders nothing. The page flows as if the widget was absent. WidgetRowView implements that branch. Decode already succeeded: AnyWidget stored UnknownWidgetContent or MalformedWidgetContent and recorded diagnostics.

swift
let registry = WidgetRegistry(unknownPolicy: .skip)

Use the default unless you have a reason. Forcing .placeholder in release turns every backend typo into a visible orange stripe in production. Forcing .skip in debug hides the very signal you wanted during development.

Fallback is not a WebView#

Unknown widgets do not degrade to HTML. They disappear or they show a labelled native placeholder. The rest of the page is still SwiftUI. That is the same native split as SDUI vs WebViews on iOS. A missing promo is better than a web blob that does not match VoiceOver or dark mode.

Version the contract additively#

The envelope is a page: id, optional nav, header, sections, footer, optional pagination, optional refresh. New optional keys on that object are safe. Old binaries ignore them. Required new keys without defaults are not safe.

Widget payloads follow the same rule. New optional fields on image_text_card decode with decodeIfPresent. Old apps skip them. New apps render them. Do not repurpose title to mean something else. Add kicker or eyebrow. Industry write-ups of additive-only props (including public posts that argue you never break a key’s meaning) are describing this discipline. Flow-UI’s Decodable structs are hand-written with decodeIfPresent for that reason.

New widget types are additive#

Ship order_card to every client today. Binaries that registered it render it. Binaries that did not record unknownType and skip or placeholder. You do not need a version bump on the document to introduce a type. You need the type string, the payload shape, and a registry entry on the apps that should show it.

Closed Codable enums fail this test. A new case is a breaking decode on old apps unless you kept an unknown case and lost the payload. The open registry keeps the payload on UnknownWidgetContent.data for tooling and still renders the page.

New mutation kinds are additive the same way#

ActionResponse.mutations is a LossyArray. An old client that does not know a future kind drops that instruction and applies the rest. Do not fail the whole api response because one instruction is newer than the binary.

Capability headers are a host convention#

Some teams send Max-Schema-Version or a list of supported widget types on the request. MVP-style max_schema_version headers in public SDUI write-ups are this idea. Flow-UI does not read those headers. It never sees HTTP.

If you want the backend to omit hologram_projector for binaries that cannot render it, attach the header in your PageLoader implementation. The loader already owns auth, cookies, and cohort flags. A capability header belongs next to those. The JSON document can stay a plain page envelope as documented in the envelope.

Do not invent a required schema_version key in page and then claim the package validates it. It does not. A host may include such a key; unknown keys on PageModel are ignored. A host may also ignore versioning entirely and rely on unknown-type fallback, which is enough for many products.

What to put in a header if you add one#

A monotonically increasing integer the backend understands. Or a comma-separated allow list of type strings this binary registered. Or both. Keep it boring. Do not encrypt it. Do not put it in widget data.

Update it when you register a new type, not on every app version unless the two are tied. Old binaries send old headers. The backend can still send unknown types; fallback remains the safety net when header and payload disagree.

Operational rules that keep old binaries honest#

  1. Additive fields only. Never change the meaning of an existing key.
  2. New types may ship any time. Old clients skip them.
  3. Defaults in decodeIfPresent must match what designers assume when the key is absent.
  4. Do not require a new App Store binary for copy, order, or which registered widget is featured.
  5. Do require a new binary for a genuinely new native control (a camera widget, a MapKit widget) because the view code is not in last month’s app. Unknown fallback will hide it, not invent it.
  6. Log unknownType in production. A sudden spike is a backend type you forgot to gate, or a typo.
  7. Keep UnknownWidgetPolicy at skip for release so customers do not see debug chrome.

Guideline 2.5.2 still cares that you are shipping data, not downloaded executable code. JSON layout is data. A script field that evals is not. Versioning the schema does not change that. See App Store Guideline 2.5.2.

When fallback is the wrong product outcome#

If the unknown widget was the only checkout CTA, skip is a silent broken funnel. That is a backend problem: do not send a type the serving binary cannot render for a required action. Capability headers help here. So does keeping critical CTAs on long-lived types (button_row) that every binary has.

If you are running an experiment that only makes sense with the new widget, target the cohort in PageLoader headers and send a page composed of types that cohort’s binary knows. Do not send an empty hole and hope.

If a surface is not a good SDUI surface at all, do not version it into JSON. When not to use server driven UI still applies.

Where this sits#

Resilience is the decode promise. Lossy decoding and diagnostics is the taxonomy. This article is how you grow the contract without a flag day. Host headers are optional. Unknown widgets are not optional. Additive props are the default.

Ship new types. Keep old keys stable. Let last month’s app skip what it does not know.

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

Additive JSON, unknown fallback, optional host headers. No built-in schema version field in the package.

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