Skip to content

Commit ca4ca69

Browse files
committed
Attempt to implement live session navigation
1 parent 57f13a6 commit ca4ca69

File tree

3 files changed

+54
-209
lines changed

3 files changed

+54
-209
lines changed

Sources/LiveViewNative/Coordinators/LiveSessionCoordinator.swift

Lines changed: 49 additions & 203 deletions
Original file line numberDiff line numberDiff line change
@@ -110,32 +110,48 @@ public class LiveSessionCoordinator<R: RootRegistry>: ObservableObject {
110110
default:
111111
false
112112
}
113+
print(next.last!.coordinator.url)
114+
print(next.last!.url)
115+
print(isDisconnected)
113116
if next.last!.coordinator.url != next.last!.url || isDisconnected {
114117
Task {
115-
if prev.count > next.count {
116-
// back navigation
117-
print("Back navigate", next.last!.url)
118-
try await prev.last?.coordinator.disconnect()
119-
let liveChannel = try await self.liveSocket!.joinLiveviewChannel(
120-
.some([
121-
"_format": .str(string: LiveSessionParameters.platform),
122-
"_interface": .object(object: LiveSessionParameters.platformParams)
123-
]),
124-
next.last!.url.absoluteString
125-
)
126-
try await next.last!.coordinator.join(liveChannel)
127-
} else if next.count > prev.count && prev.count > 0 {
128-
print("Forward navigation to \(next.last!.url)")
129-
// forward navigation (from `redirect` or `<NavigationLink>`)
130-
try await prev.last?.coordinator.disconnect()
131-
let liveChannel = try await self.liveSocket!.joinLiveviewChannel(
132-
.some([
133-
"_format": .str(string: LiveSessionParameters.platform),
134-
"_interface": .object(object: LiveSessionParameters.platformParams)
135-
]),
136-
next.last!.url.absoluteString
137-
)
138-
try await next.last?.coordinator.join(liveChannel)
118+
do {
119+
if prev.count > next.count {
120+
// back navigation
121+
try await prev.last?.coordinator.disconnect()
122+
let liveChannel = try await self.liveSocket!.joinLiveviewChannel(
123+
.some([
124+
"_format": .str(string: LiveSessionParameters.platform),
125+
"_interface": .object(object: LiveSessionParameters.platformParams)
126+
]),
127+
next.last!.url.absoluteString
128+
)
129+
try await next.last!.coordinator.join(liveChannel)
130+
} else if next.count > prev.count && prev.count > 0 {
131+
// forward navigation (from `redirect` or `<NavigationLink>`)
132+
try await prev.last?.coordinator.disconnect()
133+
let liveChannel = try await self.liveSocket!.joinLiveviewChannel(
134+
.some([
135+
"_format": .str(string: LiveSessionParameters.platform),
136+
"_interface": .object(object: LiveSessionParameters.platformParams)
137+
]),
138+
next.last!.url.absoluteString
139+
)
140+
try await next.last?.coordinator.join(liveChannel)
141+
}
142+
} catch let error as LiveSocketError {
143+
switch error {
144+
case .Phoenix(error: "server rejected join {\"reason\":\"unauthorized\"}"),
145+
.Phoenix(error: "server rejected join {\"reason\":\"stale\"}"):
146+
try await self.disconnect(preserveNavigationPath: true)
147+
let originalURL = self.url
148+
self.url = next.last!.url
149+
try await self.connect(httpMethod: nil, httpBody: nil)
150+
self.url = originalURL
151+
default:
152+
logger.error("\(error)")
153+
throw error
154+
}
139155
}
140156
}
141157
}
@@ -323,184 +339,6 @@ public class LiveSessionCoordinator<R: RootRegistry>: ObservableObject {
323339
.sink(receiveValue: handler)
324340
.store(in: &eventHandlers)
325341
}
326-
327-
/// Request the dead render with the given `request`.
328-
///
329-
/// Returns the dead render HTML and the HTTP response information (including the final URL after redirects).
330-
func deadRender(
331-
for request: URLRequest,
332-
domValues: DOMValues?
333-
) async throws -> (String, HTTPURLResponse) {
334-
335-
var request = request
336-
request.url = request.url!.appendingLiveViewItems()
337-
request.allHTTPHeaderFields = configuration.headers
338-
339-
if let domValues {
340-
request.setValue(domValues.phxCSRFToken, forHTTPHeaderField: "x-csrf-token")
341-
}
342-
343-
let data: Data
344-
let response: URLResponse
345-
do {
346-
(data, response) = try await urlSession.data(for: request)
347-
} catch {
348-
throw LiveConnectionError.initialFetchError(error)
349-
}
350-
351-
guard let response = response as? HTTPURLResponse,
352-
response.statusCode == 200,
353-
let html = String(data: data, encoding: .utf8)
354-
else {
355-
if let html = String(data: data, encoding: .utf8)
356-
{
357-
if try extractLiveReloadFrame(SwiftSoup.parse(html)) {
358-
await connectLiveReloadSocket(urlSessionConfiguration: urlSession.configuration)
359-
}
360-
throw LiveConnectionError.initialFetchUnexpectedResponse(response, html)
361-
} else {
362-
throw LiveConnectionError.initialFetchUnexpectedResponse(response)
363-
}
364-
}
365-
return (html, response)
366-
}
367-
368-
struct DOMValues {
369-
let phxCSRFToken: String
370-
let phxSession: String
371-
let phxStatic: String
372-
let phxView: String
373-
let phxID: String
374-
let liveReloadEnabled: Bool
375-
}
376-
377-
nonisolated private func extractLiveReloadFrame(_ doc: SwiftSoup.Document) throws -> Bool {
378-
!(try doc.select("iframe[src=\"/phoenix/live_reload/frame\"]").isEmpty())
379-
}
380-
381-
private func extractDOMValues(_ doc: SwiftSoup.Document) throws -> DOMValues {
382-
let csrfToken = try doc.select("csrf-token")
383-
guard !csrfToken.isEmpty() else {
384-
throw LiveConnectionError.initialParseError(missingOrInvalid: .csrfToken)
385-
}
386-
387-
let mainDivRes = try doc.select("div[data-phx-main]")
388-
guard !mainDivRes.isEmpty() else {
389-
throw LiveConnectionError.initialParseError(missingOrInvalid: .phxMain)
390-
}
391-
let mainDiv = mainDivRes[0]
392-
return .init(
393-
phxCSRFToken: try csrfToken[0].attr("value"),
394-
phxSession: try mainDiv.attr("data-phx-session"),
395-
phxStatic: try mainDiv.attr("data-phx-static"),
396-
phxView: try mainDiv.attr("data-phx-view"),
397-
phxID: try mainDiv.attr("id"),
398-
liveReloadEnabled: try extractLiveReloadFrame(doc)
399-
)
400-
}
401-
402-
@MainActor
403-
private func connectSocket(_ domValues: DOMValues) async throws {
404-
var wsEndpoint = await URLComponents(url: self.url, resolvingAgainstBaseURL: true)!
405-
wsEndpoint.scheme = await self.url.scheme == "https" ? "wss" : "ws"
406-
wsEndpoint.path = "/live/websocket"
407-
let configuration = await self.urlSession.configuration
408-
let socket = Socket(
409-
endPoint: wsEndpoint.string!,
410-
transport: {
411-
URLSessionTransport(url: $0, configuration: configuration)
412-
},
413-
paramsClosure: {
414-
[
415-
"_csrf_token": domValues.phxCSRFToken,
416-
"_format": "swiftui"
417-
]
418-
}
419-
)
420-
421-
socket.onClose { @Sendable in logger.debug("[Socket] Closed") }
422-
socket.logger = { @Sendable message in logger.debug("[Socket] \(message)") }
423-
424-
try await withCheckedThrowingContinuation { [weak self] (continuation: CheckedContinuation<Void, any Error>) in
425-
guard let self else {
426-
return continuation.resume(throwing: LiveConnectionError.sessionCoordinatorReleased)
427-
}
428-
429-
// set to `reconnecting` when the socket asks for the delay duration.
430-
socket.reconnectAfter = { @Sendable [weak self] tries in
431-
Task { @MainActor [weak self] in
432-
self?.state = .reconnecting
433-
}
434-
return Defaults.reconnectSteppedBackOff(tries)
435-
}
436-
socket.onOpen { [weak self] in
437-
Task { @MainActor [weak self] in
438-
guard case .reconnecting = await self?.state else { return }
439-
self?.state = .connected
440-
}
441-
}
442-
443-
var refs = [String]()
444-
445-
refs.append(socket.onOpen { [weak self, weak socket] in
446-
guard let socket else { return }
447-
guard self != nil else {
448-
socket.disconnect()
449-
return
450-
}
451-
logger.debug("[Socket] Opened")
452-
socket.off(refs)
453-
continuation.resume()
454-
})
455-
refs.append(socket.onError { [weak self, weak socket, refs] (error, response) in
456-
guard let socket else { return }
457-
guard self != nil else {
458-
socket.disconnect()
459-
return
460-
}
461-
logger.error("[Socket] Error: \(String(describing: error))")
462-
socket.off(refs)
463-
})
464-
}
465-
self.socket?.onClose { logger.debug("[Socket] Closed") }
466-
self.socket?.logger = { message in logger.debug("[Socket] \(message)") }
467-
468-
self.state = .connected
469-
470-
if domValues.liveReloadEnabled {
471-
await self.connectLiveReloadSocket(urlSessionConfiguration: urlSession.configuration)
472-
}
473-
}
474-
475-
private nonisolated func connectLiveReloadSocket(urlSessionConfiguration: URLSessionConfiguration) async {
476-
await MainActor.run {
477-
if let liveReloadSocket = self.liveReloadSocket {
478-
liveReloadSocket.disconnect()
479-
self.liveReloadSocket = nil
480-
}
481-
482-
var liveReloadEndpoint = URLComponents(url: self.url, resolvingAgainstBaseURL: true)!
483-
liveReloadEndpoint.scheme = self.url.scheme == "https" ? "wss" : "ws"
484-
liveReloadEndpoint.path = "/phoenix/live_reload/socket"
485-
self.liveReloadSocket = Socket(endPoint: liveReloadEndpoint.string!, transport: {
486-
URLSessionTransport(url: $0, configuration: urlSessionConfiguration)
487-
})
488-
liveReloadSocket!.connect()
489-
self.liveReloadChannel = liveReloadSocket!.channel("phoenix:live_reload")
490-
self.liveReloadChannel!.join().receive("ok") { msg in
491-
logger.debug("[LiveReload] connected to channel")
492-
}.receive("error") { msg in
493-
logger.debug("[LiveReload] error connecting to channel: \(msg.payload)")
494-
}
495-
self.liveReloadChannel!.on("assets_change") { [weak self] _ in
496-
logger.debug("[LiveReload] assets changed, reloading")
497-
Task {
498-
await StylesheetCache.shared.removeAll()
499-
// need to fully reconnect (rather than just re-join channel) because the elixir code reloader only triggers on http reqs
500-
await self?.reconnect()
501-
}
502-
}
503-
}
504342

505343
func redirect(
506344
_ redirect: LiveRedirect,
@@ -520,8 +358,16 @@ public class LiveSessionCoordinator<R: RootRegistry>: ObservableObject {
520358
self.url = redirect.to
521359
}
522360
coordinator.document = navigationPath.last!.coordinator.document
523-
// await navigationPath.last?.coordinator.disconnect()
361+
try await coordinator.disconnect()
524362
navigationPath[navigationPath.count - 1] = entry
363+
let liveChannel = try await self.liveSocket!.joinLiveviewChannel(
364+
.some([
365+
"_format": .str(string: LiveSessionParameters.platform),
366+
"_interface": .object(object: LiveSessionParameters.platformParams)
367+
]),
368+
entry.url.absoluteString
369+
)
370+
try await coordinator.join(liveChannel)
525371
// try await coordinator.connect(domValues: self.domValues, redirect: true)
526372
}
527373
}

Sources/LiveViewNative/Coordinators/LiveViewCoordinator.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -254,10 +254,10 @@ public class LiveViewCoordinator<R: RootRegistry>: ObservableObject {
254254
else { return }
255255
try await self.session.redirect(.init(kind: .push, to: destination, mode: .replaceTop))
256256
default:
257-
print("Unhandled event: \(event)")
257+
logger.error("Unhandled event: \(String(describing: event))")
258258
}
259259
} catch {
260-
print("Event handling error: \(error)")
260+
logger.error("Event handling error: \(error.localizedDescription)")
261261
}
262262
}
263263
}

Sources/LiveViewNative/Views/Layout Containers/Presentation Containers/NavigationLink.swift

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -138,17 +138,16 @@ struct NavigationLink<Root: RootRegistry>: View {
138138
SwiftUI.Button {
139139
Task { @MainActor in
140140
// send the `live_patch` event
141-
try await $liveElement.context.coordinator.doPushEvent("live_patch", payload: [
142-
"url": url.absoluteString
143-
])
141+
try await $liveElement.context.coordinator.doPushEvent("live_patch", payload: .jsonPayload(json: .object(object: [
142+
"url": .str(string: url.absoluteString)
143+
])))
144144
// update the navigation path
145145
let kind: LiveRedirect.Kind = switch linkState {
146146
case .push:
147147
.push
148148
case .replace:
149149
.replace
150150
}
151-
print(kind)
152151
try await $liveElement.context.coordinator.session.redirect(
153152
.init(
154153
kind: kind,

0 commit comments

Comments
 (0)