Fix: Stale AppEntity displayRepresentation in WidgetKit Edit Sheet After Data Changes

Fix: Stale AppEntity displayRepresentation in WidgetKit Edit Sheet After Data Changes

Written by

in

Quick answer: WidgetKit caches the DisplayRepresentation of the selected AppEntity in the configuration UI. Reloading timelines doesn’t clear this cache. The workaround is to change the entity’s ID when its display data changes, forcing WidgetKit to treat it as a new entity and re-resolve the display representation.

Why this happens

When you use AppIntentConfiguration for a widget, WidgetKit resolves the displayRepresentation of the selected AppEntity once when the user first picks it. That resolved representation is then cached by the system for the “Edit Widget” sheet. Calling WidgetCenter.shared.reloadTimelines(ofKind:) refreshes the widget’s timeline entries, but it does not invalidate the cached configuration UI.

The picker list works correctly because EntityStringQuery methods (entities(for:), entities(matching:), suggestedEntities()) are called fresh every time the user opens the picker. Your BalanceSelectionQuery fetches current data from SwiftData, so the list shows updated names. However, the already-selected row in the edit sheet continues to display the stale DisplayRepresentation that was captured at selection time.

This is a known limitation of the current AppIntents + WidgetKit integration. There is no public API to tell WidgetKit to re-resolve the display representation of an already-chosen parameter.

How to fix it

1. Change the entity ID when the display data changes (recommended workaround)

Since WidgetKit keys the cached representation by the entity’s id, giving the entity a new ID when its display name changes forces the system to treat it as a different entity. The next time the edit sheet appears, it will resolve the displayRepresentation fresh.

Update your entity to include a version or hash of the display data in its ID:

struct BalanceSelectionEntity: AppEntity {
    static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Contact")
    static var defaultQuery = BalanceSelectionQuery()

    // Combine stable contact ID with a display-data version
    let id: String
    let contactID: String
    let displayVersion: Int

    var displayRepresentation: DisplayRepresentation {
        let currentName = MutualWidgetStore.contactName(for: contactID) ?? "Contact"
        return DisplayRepresentation(title: "\(currentName)")
    }
}

When the contact’s name changes in the main app, increment a version counter (stored in UserDefaults, SwiftData, or the App Group) and rebuild the entity with the new displayVersion:

// In your contact update logic
func renameContact(_ contactID: String, to newName: String) {
    // ... update SwiftData ...

    // Bump the display version for this contact
    let key = "displayVersion.\(contactID)"
    let newVersion = (UserDefaults(suiteName: appGroupID)?.integer(forKey: key) ?? 0) + 1
    UserDefaults(suiteName: appGroupID)?.set(newVersion, forKey: key)

    // Reload widget timeline so the widget content updates
    WidgetCenter.shared.reloadTimelines(ofKind: "BalanceWidget")
}

Adjust your query to read the current version when creating entities:

struct BalanceSelectionQuery: EntityStringQuery {
    func entities(for identifiers: [String]) async throws -> [BalanceSelectionEntity] {
        let contacts = MutualWidgetStore.contacts()
        return contacts
            .filter { identifiers.contains(entityID(for: $0)) }
            .map { BalanceSelectionEntity(contact: $0) }
    }

    func entities(matching string: String) async throws -> [BalanceSelectionEntity] {
        let contacts = MutualWidgetStore.contacts()
        guard !string.isEmpty else {
            return contacts.map { BalanceSelectionEntity(contact: $0) }
        }
        return contacts
            .filter { $0.name.localizedStandardContains(string) }
            .map { BalanceSelectionEntity(contact: $0) }
    }

    func suggestedEntities() async throws -> [BalanceSelectionEntity] {
        MutualWidgetStore.contacts().map { BalanceSelectionEntity(contact: $0) }
    }

    private func entityID(for contact: ContactWidgetSummary) -> String {
        let version = UserDefaults(suiteName: appGroupID)?.integer(forKey: "displayVersion.\(contact.id)") ?? 0
        return "\(contact.id).v\(version)"
    }
}

private extension BalanceSelectionEntity {
    init(contact: ContactWidgetSummary) {
        let version = UserDefaults(suiteName: appGroupID)?.integer(forKey: "displayVersion.\(contact.id)") ?? 0
        self.contactID = contact.id
        self.displayVersion = version
        self.id = "\(contact.id).v\(version)"
    }
}

Now when the name changes, the entity ID changes. The widget configuration still holds the old entity ID, so the edit sheet will show the old name until the user re-opens the picker and selects the contact again (now with the new ID). This is the closest you can get to a refresh with current APIs.

2. Accept the limitation and document it

If changing IDs is too invasive (e.g., you rely on stable IDs for deep links or analytics), the only other option is to accept that the edit sheet may show a slightly stale name until the user re-selects the contact. The widget content itself stays correct because timeline entries are rebuilt from the timeline provider, which reads fresh data.

You can mitigate the UX impact by:
– Keeping the contact ID stable in your model
– Only using the versioned ID for the AppEntity
– Ensuring the timeline provider always reads the current name from SwiftData

How to prevent it

  • Design entity IDs to include a display-data version or hash from the start if names can change.
  • Keep the timeline provider as the source of truth for widget content; don’t rely on the configuration UI for display.
  • Test the edit sheet after any backend data mutation that affects entity display names.
  • File a feedback report (FB) with Apple requesting an API to invalidate cached DisplayRepresentation for selected parameters.

FAQ

Why does the picker list show the updated name but the selected row doesn’t?

The picker list calls your EntityStringQuery methods fresh each time it opens, so it reads current data from SwiftData. The selected row in the edit sheet uses a cached DisplayRepresentation that WidgetKit captured when the user originally chose the entity. Timeline reloads do not clear this cache.

Can I call a method to force WidgetKit to refresh the configuration UI?

No. As of iOS 17 / Xcode 15, there is no public API to invalidate or re-resolve the DisplayRepresentation of an already-selected AppEntity parameter in the widget edit sheet.

Does this affect the widget’s actual appearance on the Home Screen?

No. The widget’s visible content is driven by the timeline provider (BalanceTimelineProvider), which runs on every timeline reload and reads fresh data from SwiftData. Only the “Edit Widget” configuration sheet shows the stale name.

Sources & credits

Parts of this guide are adapted from content licensed under CC BY-SA 4.0; this article is shared under the same license.