Skip to content

Commit c5ba94d

Browse files
committed
Merge branch 'main' into 0.3
2 parents 3a32c62 + c2020d4 commit c5ba94d

File tree

32 files changed

+584
-270
lines changed

32 files changed

+584
-270
lines changed

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [unreleased]
9+
10+
## Added
11+
- `LiveViewNative.SwiftUI.normalize_os_version/1`
12+
- `LiveViewNative.SwiftUI.normalize_app_version/1`
13+
- Optional `LiveSessionConfiguration.headers`, sent when fetching the dead render (#1456)
14+
15+
## Changed
16+
- Submitting a form will remove focus from all fields (#1451)
17+
18+
## Fixed
19+
- Form elements will apply updates from a diff (#1451)
20+
- Updates to change-tracked properties no longer occur on the next RunLoop, fixing modal dismissal on macOS (#1450)
21+
- `+` characters are properly encoded as `%2B` in form events (#1449)
22+
- Fixed retain cycle in `LiveViewCoordinator` (#1455)
23+
- Made `StylesheetCache` thread-safe, fixing occasional crashes (#1461)
24+
- Form elements outside of a `LiveForm` will show the value provided in the template (#1464)
25+
826
## [0.3.0] 2024-08-21
927

1028
### Added

Sources/LiveViewNative/Coordinators/LiveSessionConfiguration.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ public struct LiveSessionConfiguration {
1515
///
1616
/// By default, no connection params are provided.
1717
public var connectParams: ((URL) -> [String: Any])? = nil
18+
19+
/// Optional headers that are sent when fetching the dead render.
20+
///
21+
/// By default, no additional headers are provided.
22+
public var headers: [String: String]? = nil
1823

1924
/// The URL session configuration the coordinator will use for performing HTTP and socket requests.
2025
///
@@ -34,10 +39,12 @@ public struct LiveSessionConfiguration {
3439

3540
public init(
3641
connectParams: ((URL) -> [String: Any])? = nil,
42+
headers: [String: String]? = nil,
3743
urlSessionConfiguration: URLSessionConfiguration = .default,
3844
transition: AnyTransition? = nil,
3945
reconnectBehavior: ReconnectBehavior = .exponential
4046
) {
47+
self.headers = headers
4148
self.connectParams = connectParams
4249
self.urlSessionConfiguration = urlSessionConfiguration
4350
self.transition = transition

Sources/LiveViewNative/Coordinators/LiveSessionCoordinator.swift

Lines changed: 80 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -128,13 +128,17 @@ public class LiveSessionCoordinator<R: RootRegistry>: ObservableObject {
128128
false
129129
}
130130
if next.last!.coordinator.url != next.last!.url || isDisconnected {
131-
Task {
132-
if prev.count > next.count {
133-
// back navigation
131+
if prev.count > next.count {
132+
// back navigation
133+
Task(priority: .userInitiated) {
134134
try await next.last!.coordinator.connect(domValues: self.domValues, redirect: true)
135-
} else if next.count > prev.count && prev.count > 0 {
136-
// forward navigation (from `redirect` or `<NavigationLink>`)
135+
}
136+
} else if next.count > prev.count && prev.count > 0 {
137+
// forward navigation (from `redirect` or `<NavigationLink>`)
138+
Task {
137139
await prev.last?.coordinator.disconnect()
140+
}
141+
Task(priority: .userInitiated) {
138142
try await next.last?.coordinator.connect(domValues: self.domValues, redirect: true)
139143
}
140144
}
@@ -193,7 +197,8 @@ public class LiveSessionCoordinator<R: RootRegistry>: ObservableObject {
193197
var request = URLRequest(url: originalURL)
194198
request.httpMethod = httpMethod
195199
request.httpBody = httpBody
196-
let (html, response) = try await deadRender(for: request)
200+
let (html, response) = try await deadRender(for: request, domValues: self.domValues)
201+
197202
// update the URL if redirects happened.
198203
let url: URL
199204
if let responseURL = response.url {
@@ -209,27 +214,37 @@ public class LiveSessionCoordinator<R: RootRegistry>: ObservableObject {
209214
} else {
210215
url = originalURL
211216
}
212-
217+
213218
let doc = try SwiftSoup.parse(html, url.absoluteString, SwiftSoup.Parser.xmlParser().settings(.init(true, true)))
214-
let domValues = try self.extractDOMValues(doc)
219+
self.domValues = try self.extractDOMValues(doc)
220+
215221
// extract the root layout, removing anything within the `<div data-phx-main>`.
216222
let mainDiv = try doc.select("div[data-phx-main]")[0]
217223
try mainDiv.replaceWith(doc.createElement("phx-main"))
218-
async let stylesheet = withThrowingTaskGroup(of: (Data, URLResponse).self) { group in
224+
225+
self.rootLayout = try LiveViewNativeCore.Document.parse(doc.outerHtml())
226+
227+
async let stylesheet = withThrowingTaskGroup(of: Stylesheet<R>.self) { group in
219228
for style in try doc.select("Style") {
220229
guard let url = URL(string: try style.attr("url"), relativeTo: url)
221230
else { continue }
222-
group.addTask { try await self.urlSession.data(from: url) }
231+
group.addTask {
232+
if let cachedStylesheet = await StylesheetCache.shared.read(for: url, registry: R.self) {
233+
return cachedStylesheet
234+
} else {
235+
let (data, _) = try await self.urlSession.data(from: url)
236+
guard let contents = String(data: data, encoding: .utf8)
237+
else { return Stylesheet<R>(content: [], classes: [:]) }
238+
let stylesheet = try Stylesheet<R>(from: contents, in: .init())
239+
await StylesheetCache.shared.write(stylesheet, for: url, registry: R.self)
240+
return stylesheet
241+
}
242+
}
223243
}
224244
return try await group.reduce(Stylesheet<R>(content: [], classes: [:])) { result, next in
225-
guard let contents = String(data: next.0, encoding: .utf8)
226-
else { return result }
227-
return result.merge(with: try Stylesheet<R>(from: contents, in: .init()))
245+
return result.merge(with: next)
228246
}
229247
}
230-
self.rootLayout = try LiveViewNativeCore.Document.parse(doc.outerHtml())
231-
232-
self.domValues = domValues
233248

234249
if socket == nil {
235250
try await self.connectSocket(domValues)
@@ -259,13 +274,20 @@ public class LiveSessionCoordinator<R: RootRegistry>: ObservableObject {
259274
}
260275

261276
private func disconnect(preserveNavigationPath: Bool = false) async {
262-
for entry in self.navigationPath {
263-
await entry.coordinator.disconnect()
264-
if !preserveNavigationPath {
265-
entry.coordinator.document = nil
277+
// disconnect all views
278+
await withTaskGroup(of: Void.self) { group in
279+
for entry in self.navigationPath {
280+
group.addTask {
281+
await entry.coordinator.disconnect()
282+
}
266283
}
267284
}
285+
// reset all documents if navigation path is being reset.
268286
if !preserveNavigationPath {
287+
for entry in self.navigationPath {
288+
entry.coordinator.document = nil
289+
}
290+
269291
self.navigationPath = [self.navigationPath.first!]
270292
}
271293
self.socket?.disconnect()
@@ -320,10 +342,16 @@ public class LiveSessionCoordinator<R: RootRegistry>: ObservableObject {
320342
/// Request the dead render with the given `request`.
321343
///
322344
/// Returns the dead render HTML and the HTTP response information (including the final URL after redirects).
323-
func deadRender(for request: URLRequest) async throws -> (String, HTTPURLResponse) {
345+
nonisolated func deadRender(
346+
for request: URLRequest,
347+
domValues: DOMValues?
348+
) async throws -> (String, HTTPURLResponse) {
349+
324350
var request = request
325-
request.url = request.url!.appendingLiveViewItems(R.self)
326-
if domValues != nil {
351+
request.url = request.url!.appendingLiveViewItems()
352+
request.allHTTPHeaderFields = configuration.headers
353+
354+
if let domValues {
327355
request.setValue(domValues.phxCSRFToken, forHTTPHeaderField: "x-csrf-token")
328356
}
329357

@@ -361,7 +389,7 @@ public class LiveSessionCoordinator<R: RootRegistry>: ObservableObject {
361389
let liveReloadEnabled: Bool
362390
}
363391

364-
private func extractLiveReloadFrame(_ doc: SwiftSoup.Document) throws -> Bool {
392+
nonisolated private func extractLiveReloadFrame(_ doc: SwiftSoup.Document) throws -> Bool {
365393
!(try doc.select("iframe[src=\"/phoenix/live_reload/frame\"]").isEmpty())
366394
}
367395

@@ -395,9 +423,12 @@ public class LiveSessionCoordinator<R: RootRegistry>: ObservableObject {
395423
var wsEndpoint = URLComponents(url: self.url, resolvingAgainstBaseURL: true)!
396424
wsEndpoint.scheme = self.url.scheme == "https" ? "wss" : "ws"
397425
wsEndpoint.path = "/live/websocket"
426+
let configuration = self.urlSession.configuration
398427
let socket = Socket(
399428
endPoint: wsEndpoint.string!,
400-
transport: { [unowned self] in URLSessionTransport(url: $0, configuration: self.urlSession.configuration) },
429+
transport: {
430+
URLSessionTransport(url: $0, configuration: configuration)
431+
},
401432
paramsClosure: {
402433
[
403434
"_csrf_token": domValues.phxCSRFToken,
@@ -479,11 +510,12 @@ public class LiveSessionCoordinator<R: RootRegistry>: ObservableObject {
479510
}.receive("error") { msg in
480511
logger.debug("[LiveReload] error connecting to channel: \(msg.payload)")
481512
}
482-
self.liveReloadChannel!.on("assets_change") { [unowned self] _ in
513+
self.liveReloadChannel!.on("assets_change") { [weak self] _ in
483514
logger.debug("[LiveReload] assets changed, reloading")
484515
Task {
516+
await StylesheetCache.shared.removeAll()
485517
// need to fully reconnect (rather than just re-join channel) because the elixir code reloader only triggers on http reqs
486-
await self.reconnect()
518+
await self?.reconnect()
487519
}
488520
}
489521
}
@@ -532,12 +564,12 @@ class LiveSessionURLSessionDelegate<R: RootRegistry>: NSObject, URLSessionTaskDe
532564
}
533565

534566
var newRequest = request
535-
newRequest.url = await url.appendingLiveViewItems(R.self)
567+
newRequest.url = await url.appendingLiveViewItems()
536568
return newRequest
537569
}
538570
}
539571

540-
extension LiveSessionCoordinator {
572+
enum LiveSessionParameters {
541573
static var platform: String { "swiftui" }
542574
static var platformParams: [String:Any] {
543575
[
@@ -639,18 +671,8 @@ extension LiveSessionCoordinator {
639671
"time_zone": TimeZone.autoupdatingCurrent.identifier,
640672
]
641673
}
642-
}
643-
644-
fileprivate extension URL {
645-
@MainActor
646-
func appendingLiveViewItems<R: RootRegistry>(_: R.Type = R.self) -> Self {
647-
var result = self
648-
let components = URLComponents(url: self, resolvingAgainstBaseURL: false)
649-
if !(components?.queryItems?.contains(where: { $0.name == "_format" }) ?? false) {
650-
result.append(queryItems: [
651-
.init(name: "_format", value: LiveSessionCoordinator<R>.platform)
652-
])
653-
}
674+
675+
static var queryItems: [URLQueryItem] = {
654676
/// Create a nested structure of query items.
655677
///
656678
/// `_root[key][nested_key]=value`
@@ -665,12 +687,24 @@ fileprivate extension URL {
665687
}
666688
}
667689
}
668-
for queryItem in queryParameters(for: LiveSessionCoordinator<R>.platformParams) {
669-
let name = "_interface\(queryItem.name)"
670-
if !(components?.queryItems?.contains(where: { $0.name == name }) ?? false) {
671-
result.append(queryItems: [.init(name: name, value: queryItem.value)])
690+
691+
return queryParameters(for: platformParams)
692+
.map { queryItem in
693+
URLQueryItem(name: "_interface\(queryItem.name)", value: queryItem.value)
672694
}
673-
}
695+
+ [.init(name: "_format", value: platform)]
696+
}()
697+
}
698+
699+
fileprivate extension URL {
700+
nonisolated func appendingLiveViewItems() -> Self {
701+
var result = self
702+
let components = URLComponents(url: self, resolvingAgainstBaseURL: false)
703+
let existingQueryItems = (components?.queryItems ?? []).reduce(into: Set<String>()) { $0.insert($1) }
704+
result.append(
705+
queryItems: LiveSessionParameters.queryItems
706+
.filter({ !existingQueryItems.contains($0.name) })
707+
)
674708
return result
675709
}
676710
}

Sources/LiveViewNative/Coordinators/LiveViewCoordinator.swift

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ public class LiveViewCoordinator<R: RootRegistry>: ObservableObject {
3232
internalState
3333
}
3434

35-
@_spi(LiveForm) public let session: LiveSessionCoordinator<R>
35+
@_spi(LiveForm) public private(set) weak var session: LiveSessionCoordinator<R>!
3636
var url: URL
3737

3838
private var channel: Channel?
@@ -56,16 +56,22 @@ public class LiveViewCoordinator<R: RootRegistry>: ObservableObject {
5656
private(set) internal var eventSubject = PassthroughSubject<(String, Payload), Never>()
5757
private(set) internal var eventHandlers = Set<AnyCancellable>()
5858

59-
init(session: LiveSessionCoordinator<R>, url: URL) {
59+
private(set) internal var liveViewModel = LiveViewModel()
60+
61+
init(
62+
session: LiveSessionCoordinator<R>,
63+
url: URL
64+
) {
6065
self.session = session
6166
self.url = url
6267

6368
self.handleEvent("native_redirect") { [weak self] payload in
6469
guard let self,
65-
let redirect = LiveRedirect(from: payload, relativeTo: self.url)
70+
let redirect = LiveRedirect(from: payload, relativeTo: self.url),
71+
let session = self.session
6672
else { return }
67-
Task { [weak session] in
68-
try await session?.redirect(redirect)
73+
Task {
74+
try await session.redirect(redirect)
6975
}
7076
}
7177
}
@@ -238,7 +244,7 @@ public class LiveViewCoordinator<R: RootRegistry>: ObservableObject {
238244
connectParams["_mounts"] = 0
239245
connectParams["_format"] = "swiftui"
240246
connectParams["_csrf_token"] = domValues.phxCSRFToken
241-
connectParams["_interface"] = LiveSessionCoordinator<R>.platformParams
247+
connectParams["_interface"] = LiveSessionParameters.platformParams
242248

243249
let params: Payload = [
244250
"session": domValues.phxSession,
@@ -324,10 +330,10 @@ public class LiveViewCoordinator<R: RootRegistry>: ObservableObject {
324330
continuation.resume()
325331
}
326332
}
327-
}
328-
await MainActor.run { [weak self] in
329-
self?.channel = nil
330-
self?.internalState = .disconnected
333+
await MainActor.run { [weak self] in
334+
self?.channel = nil
335+
self?.internalState = .disconnected
336+
}
331337
}
332338
}
333339

@@ -393,7 +399,13 @@ public class LiveViewCoordinator<R: RootRegistry>: ObservableObject {
393399
private func handleJoinPayload(renderedPayload: Payload) {
394400
// todo: what should happen if decoding or parsing fails?
395401
self.rendered = try! Root(from: FragmentDecoder(data: renderedPayload))
402+
403+
// FIXME: LiveForm should send change event when restored from `liveViewModel`.
404+
// For now, we just clear the forms whenever the page reconnects.
405+
self.liveViewModel.clearForms()
406+
396407
self.document = try! LiveViewNativeCore.Document.parse(rendered.buildString())
408+
397409
self.document?.on(.changed) { [unowned self] doc, nodeRef in
398410
switch doc[nodeRef].data {
399411
case .root:

Sources/LiveViewNative/Live/LiveErrorView.swift

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,25 +39,36 @@ public struct LiveErrorView<Fallback: View>: View {
3939
}
4040
.padding()
4141
#else
42-
SwiftUI.Menu {
43-
SwiftUI.Button {
44-
Task {
45-
await reconnectLiveView(.automatic)
42+
if #available(iOS 14, macOS 11, tvOS 17, visionOS 1, *) {
43+
SwiftUI.Menu {
44+
SwiftUI.Button {
45+
Task {
46+
await reconnectLiveView(.automatic)
47+
}
48+
} label: {
49+
SwiftUI.Label("Reconnect this page", systemImage: "arrow.2.circlepath")
50+
}
51+
SwiftUI.Button {
52+
Task {
53+
await reconnectLiveView(.restart)
54+
}
55+
} label: {
56+
SwiftUI.Label("Restart from root", systemImage: "arrow.circlepath")
4657
}
4758
} label: {
48-
SwiftUI.Label("Reconnect this page", systemImage: "arrow.2.circlepath")
59+
SwiftUI.Label("Reconnect", systemImage: "arrow.2.circlepath")
4960
}
61+
.padding()
62+
} else {
5063
SwiftUI.Button {
5164
Task {
5265
await reconnectLiveView(.restart)
5366
}
5467
} label: {
5568
SwiftUI.Label("Restart from root", systemImage: "arrow.circlepath")
5669
}
57-
} label: {
58-
SwiftUI.Label("Reconnect", systemImage: "arrow.2.circlepath")
70+
.padding()
5971
}
60-
.padding()
6172
#endif
6273
}
6374
} else {

0 commit comments

Comments
 (0)