Skip to content

Commit 0ff7e5f

Browse files
committed
LoadableView: add support for viewDidLoadActionID
When loading views dynamically the client needs to receive a callback when the view loads in order to be able to access the subviews/controls. Setting a different json value for LoadableView does not load it synchronously in-place so there was a race condition to access the load subviews. The clients can now declare viewDidLoadActionID in LoadableView json and register a callback for that ID to get their action handler called when the loaded view is ready for access.
1 parent 6fa28b9 commit 0ff7e5f

4 files changed

Lines changed: 201 additions & 9 deletions

File tree

ActionUI/Common/FileLoadableView.swift

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,25 @@ import SwiftUI
88
// View for synchronous file-based loading (local file or bundle resource)
99
@MainActor
1010
public struct FileLoadableView: SwiftUI.View {
11+
// Static dedup tracking — avoids using @Published ViewModel.states which would trigger re-renders
12+
private static var loadedSources: [String: String] = [:]
13+
1114
let fileURL: URL
1215
let windowUUID: String
1316
let isContentView: Bool
1417
let parentID: Int
18+
let viewDidLoadActionID: String?
1519
let logger: any ActionUILogger
1620

1721
private let element: ActionUIElement?
1822
private let error: Error?
1923

20-
public init(fileURL: URL, windowUUID: String, isContentView: Bool, parentID: Int = 0, logger: any ActionUILogger) {
24+
public init(fileURL: URL, windowUUID: String, isContentView: Bool, parentID: Int = 0, viewDidLoadActionID: String? = nil, logger: any ActionUILogger) {
2125
self.fileURL = fileURL
2226
self.windowUUID = windowUUID
2327
self.isContentView = isContentView
2428
self.parentID = parentID
29+
self.viewDidLoadActionID = viewDidLoadActionID
2530
self.logger = logger
2631

2732
// Perform synchronous loading in init
@@ -36,13 +41,22 @@ public struct FileLoadableView: SwiftUI.View {
3641
}
3742
logger.log("Successfully loaded \(format) for LoadableView from file \(fileURL)", .debug)
3843
self.error = nil
44+
// Defer fireViewDidLoad to after current body evaluation completes
45+
// Uses static dedup so it only fires once per unique source
46+
let capturedFileURL = fileURL
47+
let capturedWindowUUID = windowUUID
48+
let capturedParentID = parentID
49+
let capturedActionID = viewDidLoadActionID
50+
Task { @MainActor in
51+
Self.fireViewDidLoad(fileURL: capturedFileURL, windowUUID: capturedWindowUUID, parentID: capturedParentID, viewDidLoadActionID: capturedActionID)
52+
}
3953
} catch {
4054
self.element = nil
4155
self.error = error
4256
logger.log("Failed to load description for LoadableView from file \(fileURL): \(error)", .error)
4357
}
4458
}
45-
59+
4660
public var body: some SwiftUI.View {
4761
if let error = error {
4862
SwiftUI.Text("Failed to load view: \(error.localizedDescription)")
@@ -55,4 +69,15 @@ public struct FileLoadableView: SwiftUI.View {
5569
SwiftUI.Text("No content loaded")
5670
}
5771
}
72+
73+
private static func fireViewDidLoad(fileURL: URL, windowUUID: String, parentID: Int, viewDidLoadActionID: String?) {
74+
guard let actionID = viewDidLoadActionID else { return }
75+
guard ActionUIModel.shared.windowModels[windowUUID]?.viewModels[parentID] != nil else { return }
76+
77+
let key = "\(windowUUID)_\(parentID)"
78+
let source = fileURL.absoluteString
79+
guard loadedSources[key] != source else { return }
80+
loadedSources[key] = source
81+
ActionUIModel.shared.actionHandler(actionID, windowUUID: windowUUID, viewID: parentID, viewPartID: 0)
82+
}
5883
}

ActionUI/Common/RemoteLoadableView.swift

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,23 +8,28 @@ import SwiftUI
88
// View for remote asynchronous loading
99
@MainActor
1010
public struct RemoteLoadableView: SwiftUI.View {
11+
// Static dedup tracking — avoids using @Published ViewModel.states which would trigger re-renders
12+
private static var loadedSources: [String: String] = [:]
13+
1114
let url: URL
1215
let windowUUID: String
1316
let isContentView: Bool
1417
let parentID: Int
18+
let viewDidLoadActionID: String?
1519
let logger: any ActionUILogger
1620

1721
@State private var element: ActionUIElement?
1822
@State private var error: Error?
1923

20-
public init(url: URL, windowUUID: String, isContentView: Bool, parentID: Int = 0, logger: any ActionUILogger) {
24+
public init(url: URL, windowUUID: String, isContentView: Bool, parentID: Int = 0, viewDidLoadActionID: String? = nil, logger: any ActionUILogger) {
2125
self.url = url
2226
self.windowUUID = windowUUID
2327
self.isContentView = isContentView
2428
self.parentID = parentID
29+
self.viewDidLoadActionID = viewDidLoadActionID
2530
self.logger = logger
2631
}
27-
32+
2833
public var body: some SwiftUI.View {
2934
if let error = error {
3035
SwiftUI.Text("Failed to load view: \(error.localizedDescription)")
@@ -41,7 +46,7 @@ public struct RemoteLoadableView: SwiftUI.View {
4146
var request = URLRequest(url: url)
4247
request.cachePolicy = .reloadRevalidatingCacheData // Balances freshness and performance
4348
let (data, _) = try await URLSession.shared.data(for: request)
44-
49+
4550
// Determine format based on URL extension
4651
let format = url.pathExtension.lowercased() == "plist" ? "plist" : "json"
4752
logger.log("Determined format '\(format)' for remote URL \(url)", .debug)
@@ -51,6 +56,7 @@ public struct RemoteLoadableView: SwiftUI.View {
5156
element = try ActionUIModel.shared.loadSubViewDescription(from: data, format: format, windowUUID: windowUUID, parentID: parentID)
5257
}
5358
logger.log("Successfully loaded \(format) for LoadableView from remote \(url)", .debug)
59+
fireViewDidLoad()
5460
} catch {
5561
self.error = error
5662
logger.log("Failed to load remote description for LoadableView from \(url): \(error)", .error)
@@ -59,4 +65,15 @@ public struct RemoteLoadableView: SwiftUI.View {
5965
}
6066
}
6167
}
68+
69+
private func fireViewDidLoad() {
70+
guard let actionID = viewDidLoadActionID else { return }
71+
guard ActionUIModel.shared.windowModels[windowUUID]?.viewModels[parentID] != nil else { return }
72+
73+
let key = "\(windowUUID)_\(parentID)"
74+
let source = url.absoluteString
75+
guard Self.loadedSources[key] != source else { return }
76+
Self.loadedSources[key] = source
77+
ActionUIModel.shared.actionHandler(actionID, windowUUID: windowUUID, viewID: parentID, viewPartID: 0)
78+
}
6279
}

ActionUI/Views/LoadableView.swift

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
"properties": {
88
"url": "https://example.com/view.json", // Optional: String URL to remote JSON or binary plist (http://, https://)
99
"filePath": "/path/to/view.json", // Optional: String absolute path to local JSON or binary plist
10-
"name": "HelloWorld.json" // Optional: String name of JSON or binary plist resource in app bundle
10+
"name": "HelloWorld.json", // Optional: String name of JSON or binary plist resource in app bundle
11+
"viewDidLoadActionID": "view.loaded" // Optional: String action triggered once after a new sub-view is loaded. Not re-triggered on SwiftUI body rebuilds for the same source.
1112
}
1213
// Note: Requires exactly one of "url", "filePath", or "name" to be valid. Loads JSON or binary plist, determined by .json or .plist extension, parses into ActionUIElementBase using ActionUIModel.loadDescription, and renders ActionUIView. Remote "url" loads asynchronously with ProgressView; local "filePath" or bundle "name" loads synchronously in init. Assumes unique IDs in loaded description to avoid conflicts with existing windowModels. Baseline View properties (padding, hidden, foregroundColor, font, background, frame, opacity, cornerRadius, actionID, disabled) and additional View protocol modifiers are inherited and applied via ActionUIRegistry.shared.applyViewModifiers(to: baseView, properties: element.properties).
1314
// Note: Invalid sources or unsupported extensions will result in error display. The source (url/filePath/name) is the designated value (valueType: String.self), settable via ActionUIModel.setElementValue, with heuristics: http:// or https:// for URL, file:// or / for filePath, else bundle name.
@@ -57,8 +58,9 @@ struct LoadableView: ActionUIViewConstruction {
5758
}
5859

5960
static var buildView: (any ActionUIElementBase, ViewModel, String, [String: Any], any ActionUILogger) -> any SwiftUI.View = { element, model, windowUUID, properties, logger in
60-
61+
6162
let isContentView = false
63+
let viewDidLoadActionID = properties["viewDidLoadActionID"] as? String
6264
// Use model.value if set (heuristics to interpret), else fallback to validated properties
6365
guard let value = Self.initialValue(model) as? String, !value.isEmpty else {
6466
logger.log("No valid source for LoadableView, displaying error SwiftUI.Text", .warning)
@@ -72,7 +74,7 @@ struct LoadableView: ActionUIViewConstruction {
7274
return SwiftUI.Text("Invalid URL: \(value)")
7375
}
7476
logger.log("Interpreting value as remote URL: \(value)", .debug)
75-
return RemoteLoadableView(url: url, windowUUID: windowUUID, isContentView: isContentView, parentID: element.id, logger: logger)
77+
return RemoteLoadableView(url: url, windowUUID: windowUUID, isContentView: isContentView, parentID: element.id, viewDidLoadActionID: viewDidLoadActionID, logger: logger)
7678
} else {
7779
var fileURL: URL
7880
if value.lowercased().hasPrefix("file://") {
@@ -97,7 +99,7 @@ struct LoadableView: ActionUIViewConstruction {
9799
fileURL = url
98100
logger.log("Interpreting value as bundle name: \(value)", .debug)
99101
}
100-
return FileLoadableView(fileURL: fileURL, windowUUID: windowUUID, isContentView: isContentView, parentID: element.id, logger: logger)
102+
return FileLoadableView(fileURL: fileURL, windowUUID: windowUUID, isContentView: isContentView, parentID: element.id, viewDidLoadActionID: viewDidLoadActionID, logger: logger)
101103
}
102104
}
103105

ActionUITests/Views/LoadableViewTests.swift

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,4 +284,152 @@ final class LoadableViewTests: XCTestCase {
284284
XCTAssertNotNil(windowModel.viewModels[1], "Root VStack model should be preserved after sub-view swap")
285285
XCTAssertNotNil(windowModel.viewModels[100], "LoadableView model should be preserved after sub-view swap")
286286
}
287+
288+
// MARK: - viewDidLoadActionID tests
289+
290+
/// Helper: loads a root window with a LoadableView that has viewDidLoadActionID set.
291+
private func loadRootWithViewDidLoadAction() throws -> WindowModel {
292+
let rootJSON = """
293+
{
294+
"id": 1,
295+
"type": "VStack",
296+
"properties": {},
297+
"children": [
298+
{ "id": 100, "type": "LoadableView", "properties": { "filePath": "/tmp/panel.json", "viewDidLoadActionID": "params.loaded" } }
299+
]
300+
}
301+
"""
302+
let data = rootJSON.data(using: .utf8)!
303+
_ = try ActionUIModel.shared.loadDescription(from: data, format: "json", windowUUID: windowUUID)
304+
return ActionUIModel.shared.windowModels[windowUUID]!
305+
}
306+
307+
/// Tracks dedup state for simulateFireViewDidLoad, mirroring the static dict in FileLoadableView/RemoteLoadableView.
308+
private var simulatedLoadedSources: [String: String] = [:]
309+
310+
/// Simulates the fireViewDidLoad logic used by FileLoadableView/RemoteLoadableView.
311+
/// This avoids needing SwiftUI view instantiation in unit tests.
312+
private func simulateFireViewDidLoad(parentID: Int, source: String, actionID: String) {
313+
guard !actionID.isEmpty else { return }
314+
guard ActionUIModel.shared.windowModels[windowUUID]?.viewModels[parentID] != nil else { return }
315+
316+
let key = "\(windowUUID!)_\(parentID)"
317+
guard simulatedLoadedSources[key] != source else { return }
318+
simulatedLoadedSources[key] = source
319+
ActionUIModel.shared.actionHandler(actionID, windowUUID: windowUUID, viewID: parentID, viewPartID: 0)
320+
}
321+
322+
func testViewDidLoadActionIDPropertyValidation() throws {
323+
let windowModel = try loadRootWithViewDidLoadAction()
324+
let viewModel = windowModel.viewModels[100]!
325+
XCTAssertEqual(viewModel.validatedProperties["viewDidLoadActionID"] as? String, "params.loaded",
326+
"viewDidLoadActionID should be preserved in validated properties")
327+
}
328+
329+
func testViewDidLoadFiresOnFirstLoad() throws {
330+
let windowModel = try loadRootWithViewDidLoadAction()
331+
let parentID = 100
332+
333+
// Load sub-view content
334+
let subData = subViewJSON(rootID: 200, childIDs: [201])
335+
_ = try windowModel.loadSubViewDescription(from: subData, format: "json", parentID: parentID)
336+
337+
// Track action handler calls
338+
var firedActions: [(String, Int)] = []
339+
ActionUIModel.shared.setDefaultActionHandler { actionID, _, viewID, _, _ in
340+
firedActions.append((actionID, viewID))
341+
}
342+
343+
// Simulate fireViewDidLoad
344+
simulateFireViewDidLoad(parentID: parentID, source: "file:///tmp/panel.json", actionID: "params.loaded")
345+
346+
XCTAssertEqual(firedActions.count, 1, "Action should fire once")
347+
XCTAssertEqual(firedActions[0].0, "params.loaded", "Action ID should match")
348+
XCTAssertEqual(firedActions[0].1, parentID, "View ID should be the LoadableView's ID")
349+
}
350+
351+
func testViewDidLoadDoesNotReFireForSameSource() throws {
352+
let windowModel = try loadRootWithViewDidLoadAction()
353+
let parentID = 100
354+
355+
let subData = subViewJSON(rootID: 200, childIDs: [201])
356+
_ = try windowModel.loadSubViewDescription(from: subData, format: "json", parentID: parentID)
357+
358+
var fireCount = 0
359+
ActionUIModel.shared.setDefaultActionHandler { _, _, _, _, _ in
360+
fireCount += 1
361+
}
362+
363+
// First call should fire
364+
simulateFireViewDidLoad(parentID: parentID, source: "file:///tmp/panel.json", actionID: "params.loaded")
365+
XCTAssertEqual(fireCount, 1, "Should fire on first call")
366+
367+
// Second call with same source should not fire (simulates SwiftUI body rebuild)
368+
simulateFireViewDidLoad(parentID: parentID, source: "file:///tmp/panel.json", actionID: "params.loaded")
369+
XCTAssertEqual(fireCount, 1, "Should not re-fire for the same source")
370+
}
371+
372+
func testViewDidLoadFiresAgainForNewSource() throws {
373+
let windowModel = try loadRootWithViewDidLoadAction()
374+
let parentID = 100
375+
376+
var firedSources: [String] = []
377+
ActionUIModel.shared.setDefaultActionHandler { actionID, _, _, _, _ in
378+
firedSources.append(actionID)
379+
}
380+
381+
// First source
382+
let subData1 = subViewJSON(rootID: 200, childIDs: [201])
383+
_ = try windowModel.loadSubViewDescription(from: subData1, format: "json", parentID: parentID)
384+
simulateFireViewDidLoad(parentID: parentID, source: "file:///tmp/brightness.json", actionID: "params.loaded")
385+
386+
// Replace with new source
387+
let subData2 = subViewJSON(rootID: 300, childIDs: [301])
388+
_ = try windowModel.loadSubViewDescription(from: subData2, format: "json", parentID: parentID)
389+
simulateFireViewDidLoad(parentID: parentID, source: "file:///tmp/contrast.json", actionID: "params.loaded")
390+
391+
XCTAssertEqual(firedSources.count, 2, "Should fire for each new source")
392+
}
393+
394+
func testViewDidLoadNotFiredWithoutActionID() throws {
395+
// Use the helper without viewDidLoadActionID
396+
let windowModel = try loadRootWithLoadableView()
397+
let parentID = 100
398+
399+
let subData = subViewJSON(rootID: 200, childIDs: [201])
400+
_ = try windowModel.loadSubViewDescription(from: subData, format: "json", parentID: parentID)
401+
402+
var fireCount = 0
403+
ActionUIModel.shared.setDefaultActionHandler { _, _, _, _, _ in
404+
fireCount += 1
405+
}
406+
407+
// Empty actionID should not fire (mirrors nil viewDidLoadActionID in real code)
408+
simulateFireViewDidLoad(parentID: parentID, source: "file:///tmp/panel.json", actionID: "")
409+
XCTAssertEqual(fireCount, 0, "Should not fire when actionID is empty")
410+
}
411+
412+
func testFullReloadClearsViewModels() throws {
413+
let windowModel = try loadRootWithViewDidLoadAction()
414+
let parentID = 100
415+
416+
let subData = subViewJSON(rootID: 200, childIDs: [201])
417+
_ = try windowModel.loadSubViewDescription(from: subData, format: "json", parentID: parentID)
418+
419+
var fireCount = 0
420+
ActionUIModel.shared.setDefaultActionHandler { _, _, _, _, _ in
421+
fireCount += 1
422+
}
423+
424+
simulateFireViewDidLoad(parentID: parentID, source: "file:///tmp/panel.json", actionID: "params.loaded")
425+
XCTAssertEqual(fireCount, 1)
426+
427+
// Full window reload should clear all ViewModels
428+
let rootJSON = """
429+
{ "id": 1, "type": "Text", "properties": { "text": "fresh" } }
430+
"""
431+
_ = try windowModel.loadDescription(from: rootJSON.data(using: .utf8)!, format: "json")
432+
433+
XCTAssertNil(windowModel.viewModels[parentID], "Old LoadableView ViewModel should be gone after full reload")
434+
}
287435
}

0 commit comments

Comments
 (0)