// @ts-check const fs = require("fs"); const path = require("path"); const { withDangerousMod } = require("@expo/config-plugins"); const dynamicViewPath = path.join( "node_modules", "expo-widgets", "ios", "Widgets", "DynamicView.swift", ); // expo-widgets rebuilds every WidgetsDynamicView child with a brand-new random UUID // on every single render (see the original "Hack to satisfy ExpoSwiftUI.AnyChild" // comment), so the `ForEach(props.children, id: \.id)` that HStack/VStack use to // render their children sees a totally different identity set on each re-render. // SwiftUI then treats every child as removed-and-reinserted instead of updated in // place, which can make children silently fail to render whenever the parent's // layout is recomputed (e.g. after a `.frame(maxWidth:)` change). This patch gives // each child a stable identity derived from its structural position, cached across // renders, so ForEach can correctly diff instead of thrashing. const patchedSource = `import SwiftUI import ExpoModulesCore import ExpoUI // TODO(@jakex7): Hack to satisfy ExpoSwiftUI.AnyChild with random UUID value class NodeIdentityWrapper { let id: UUID init(id: UUID) { self.id = id } } // Reuses the same NodeIdentityWrapper for a given structural key across re-renders, // so ForEach(id: \\.id) sees a stable identity instead of a fresh UUID every render. private final class NodeIdentityCache { static let shared = NodeIdentityCache() private var wrappers: [String: NodeIdentityWrapper] = [:] func wrapper(for key: String) -> NodeIdentityWrapper { if let existing = wrappers[key] { return existing } let created = NodeIdentityWrapper(id: UUID()) wrappers[key] = created return created } } extension ObjectIdentifier: @retroactive Encodable { public func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() try container.encode(String(describing: self)) } } public struct WidgetsDynamicView: View, ExpoSwiftUI.AnyChild { let node: [String: Any] let name: String let kind: WidgetsKind let entryIndex: Int? let environmentString: String? let childKey: String? private var uuid: NodeIdentityWrapper { guard let childKey else { return NodeIdentityWrapper(id: UUID()) } return NodeIdentityCache.shared.wrapper(for: childKey) } public var id: ObjectIdentifier { ObjectIdentifier(uuid) } public init(name: String, kind: WidgetsKind, node: [String: Any]) { self.name = name self.kind = kind self.node = node self.entryIndex = nil self.environmentString = nil self.childKey = node["type"] as? String } public init(name: String, kind: WidgetsKind, node: [String: Any], entryIndex: Int?, environmentString: String?) { self.name = name self.kind = kind self.node = node self.entryIndex = entryIndex self.environmentString = environmentString self.childKey = node["type"] as? String } private init(name: String, kind: WidgetsKind, node: [String: Any], entryIndex: Int?, environmentString: String?, childKey: String) { self.name = name self.kind = kind self.node = node self.entryIndex = entryIndex self.environmentString = environmentString self.childKey = childKey } @ViewBuilder public var body: some View { switch node["type"] as? String { case "TextView": render(TextView.self, TextViewProps.self, updateProps: updateChildren) case "HStackView": render(HStackView.self, HStackViewProps.self, updateProps: updateChildren) case "VStackView": render(VStackView.self, VStackViewProps.self, updateProps: updateChildren) case "ZStackView": render(ZStackView.self, ZStackViewProps.self, updateProps: updateChildren) case "RectangleView": render(RectangleView.self, RectangleViewProps.self) case "RoundedRectangleView": render(RoundedRectangleView.self, RoundedRectangleViewProps.self) case "CapsuleView": render(CapsuleView.self, CapsuleViewProps.self) case "CircleView": render(CircleView.self, CircleViewProps.self) case "ImageView": render(ImageView.self, ImageViewProps.self) case "AccessoryWidgetBackgroundView": render(AccessoryWidgetBackgroundView.self, AccessoryWidgetBackgroundProps.self) case "DividerView": render(DividerView.self, DividerProps.self) case "EllipseView": render(EllipseView.self, EllipseViewProps.self) case "LabelView": render(LabelView.self, LabelViewProps.self) case "ProgressView": render(ProgressView.self, ProgressViewProps.self) case "SpacerView": render(SpacerView.self, SpacerViewProps.self) case "UnevenRoundedRectangleView": render(UnevenRoundedRectangleView.self, UnevenRoundedRectangleViewProps.self) case "GaugeView": render(GaugeView.self, GaugeProps.self) case "ChartView": render(ChartView.self, ChartProps.self) case "Button": if #available(iOS 17.0, *) { switch kind { case .widget: render(WidgetButtonView.self, ButtonProps.self) { buttonProps in try updateChildren(buttonProps) buttonProps.source = name buttonProps.entryIndex = entryIndex buttonProps.environmentString = environmentString } case .liveActivity: render(LiveActivityButtonView.self, ButtonProps.self) { buttonProps in try updateChildren(buttonProps) buttonProps.source = name } } } else { render(ExpoUI.Button.self, ExpoUI.ButtonProps.self, updateProps: updateChildren) } case "react.fragment": render(FragmentView.self, FragmentProps.self, updateProps: updateChildren) case "LinkView": render(LinkView.self, LinkViewProps.self, updateProps: updateChildren) #if DEBUG case "RedBoxView": render(RedBoxView.self, RedBoxViewProps.self) { redBoxProps in redBoxProps.source = name redBoxProps.kind = kind } default: ZStack { Color.red.opacity(0.5) Text("Unable to get the view for: \\(node["type"] as? String ?? "undefined")") } #else default: EmptyView() #endif } } // MARK: - Render Method @ViewBuilder private func render
(_ viewType: V.Type, _ propsType: P.Type, updateProps: ((_ initialProps: P) throws -> Void)? = nil) -> some View where P: UIBaseViewProps, V: ExpoSwiftUI.View, V.Props == P { // immediately invoked closure {}() here because we can't use 'do-catch' inside @ViewBuilder { do { if let rawProps = node["props"] as? [String: Any] { let props = try propsType.init(rawProps: rawProps, context: WidgetsContext.shared.context) try updateProps?(props) return AnyView(UIBaseView
(props: props).transition(.identity)) } return AnyView(EmptyView()) } catch { return AnyView(EmptyView()) } }() } // MARK: - Function that sets children as DynamicView private func updateChildren
(_ initialProps: P) throws where P: UIBaseViewProps { let baseKey = childKey ?? (node["type"] as? String ?? "root") if let props = node["props"] as? [String: Any] { if let children = props["children"] as? [Any] { let validChildren = children.compactMap { $0 as? [String: Any] } initialProps.children = validChildren.enumerated().map { index, childNode in let childType = childNode["type"] as? String ?? "unknown" return WidgetsDynamicView( name: name, kind: kind, node: childNode, entryIndex: entryIndex, environmentString: environmentString, childKey: "\\(baseKey).\\(index).\\(childType)" ) } } else if let child = props["children"] as? [String: Any] { let childType = child["type"] as? String ?? "unknown" initialProps.children = [WidgetsDynamicView( name: name, kind: kind, node: child, entryIndex: entryIndex, environmentString: environmentString, childKey: "\\(baseKey).0.\\(childType)" )] } } } } `; /** @type {import('@expo/config-plugins').ConfigPlugin} */ function withStableWidgetsChildIdentity(config) { return withDangerousMod(config, [ "ios", async (config) => { const filePath = path.join(config.modRequest.projectRoot, dynamicViewPath); if (!fs.existsSync(filePath)) { throw new Error(`Could not find expo-widgets DynamicView.swift at ${filePath}`); } fs.writeFileSync(filePath, patchedSource); return config; }, ]); } module.exports = withStableWidgetsChildIdentity;