Skip to content

Post Refactor update to session coordinator #1518

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 100 additions & 93 deletions Sources/LiveViewNative/Coordinators/LiveSessionCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,6 @@ public class LiveSessionCoordinator<R: RootRegistry>: ObservableObject {
@Published private(set) var rootLayout: LiveViewNativeCore.Document?
@Published private(set) var stylesheet: Stylesheet<R>?

// Socket connection
var liveSocket: LiveViewNativeCore.LiveSocket?
var socket: LiveViewNativeCore.Socket?

private var liveReloadChannel: LiveViewNativeCore.LiveChannel?
private var liveReloadListener: Channel.EventStream?
private var liveReloadListenerLoop: Task<(), any Error>?
Expand All @@ -60,6 +56,17 @@ public class LiveSessionCoordinator<R: RootRegistry>: ObservableObject {

private var reconnectAttempts = 0

// TODO: Once this works sub out the rest
private var persistence: SimplePersistentStore
private var eventHandler: SimpleEventHandler
private var patchHandler: SimplePatchHandler
private var navHandler: SimpleNavHandler

private var liveviewClient: LiveViewClient?
private var builder: LiveViewClientBuilder



/// Positions for `<ScrollView>` elements with an explicit ID.
///
/// These positions are used for scroll restoration on back navigation.
Expand All @@ -80,20 +87,38 @@ public class LiveSessionCoordinator<R: RootRegistry>: ObservableObject {
public convenience init(_ host: some LiveViewHost, config: LiveSessionConfiguration = .init(), customRegistryType: R.Type = R.self) {
self.init(host.url, config: config, customRegistryType: customRegistryType)
}


public func clientChannel() -> LiveViewClientChannel? {
self.liveviewClient?.channel()
}

public func status() -> SocketStatus {
(try? self.liveviewClient?.status()) ?? .disconnected
}

/// Creates a new coordinator with a custom registry.
/// - Parameter url: The URL of the page to establish the connection to.
/// - Parameter config: The configuration for this coordinator.
/// - Parameter customRegistryType: The type of the registry of custom views this coordinator will use when building the SwiftUI view tree from the DOM. This can generally be inferred automatically.
public init(_ url: URL, config: LiveSessionConfiguration = .init(), customRegistryType _: R.Type = R.self) {
self.url = url.appending(path: "").absoluteURL


self.patchHandler = SimplePatchHandler()
self.eventHandler = SimpleEventHandler()
self.navHandler = SimpleNavHandler()
self.persistence = SimplePersistentStore()

self.builder = LiveViewClientBuilder();

self.builder.setPatchHandler(patchHandler)
self.builder.setNavigationHandler(navHandler)
self.builder.setPersistenceProvider(persistence)
self.builder.setLiveChannelEventHandler(eventHandler)
self.builder.setLogLevel(.debug)

self.configuration = config

// load cookies into core
for cookie in HTTPCookieStorage.shared.cookies(for: url) ?? [] {
try? LiveViewNativeCore.storeSessionCookie("\(cookie.name)=\(cookie.value)", self.url.absoluteString)
}

self.navigationPath = [.init(url: url, coordinator: .init(session: self, url: self.url), navigationTransition: nil, pendingView: nil)]

Expand All @@ -103,26 +128,40 @@ public class LiveSessionCoordinator<R: RootRegistry>: ObservableObject {
.sink(receiveValue: { [weak self] value in
self?.eventSubject.send(value)
})

self.eventHandler.viewReloadSubject
.receive(on: DispatchQueue.main)
.sink { [weak self] newView in
guard let self else { return }
guard let last = self.navigationPath.last else { return }
if let client = self.liveviewClient {
last.coordinator.join(client, self.eventHandler, self.patchHandler)
}
}.store(in: &cancellables)

$navigationPath.scan(([LiveNavigationEntry<R>](), [LiveNavigationEntry<R>]()), { ($0.1, $1) }).sink { [weak self] prev, next in
guard let self else { return }
guard let client = liveviewClient else { return }
Task {
try await prev.last?.coordinator.disconnect()
prev.last?.coordinator.disconnect()
if prev.count > next.count {
// back navigation (we could be going back multiple pages at once, so use `traverseTo` instead of `back`)
let targetEntry = self.liveSocket!.getEntries()[next.count - 1]
next.last?.coordinator.join(
try await self.liveSocket!.traverseTo(targetEntry.id, next.last!.coordinator.liveChannel, nil)
)
var opts = NavActionOptions()
opts.joinParams = .some([ "_interface": .object(object: LiveSessionParameters.platformParams)])
let targetEntry = client.getEntries()[next.count - 1]
let _ = try await client.traverseTo(targetEntry.id, opts)
} else if next.count > prev.count && prev.count > 0 {
// forward navigation (from `redirect` or `<NavigationLink>`)
next.last?.coordinator.join(
try await self.liveSocket!.navigate(next.last!.url.absoluteString, next.last!.coordinator.liveChannel, NavOptions(action: .push))
)
var opts = NavOptions()
opts.joinParams = .some([ "_interface": .object(object: LiveSessionParameters.platformParams)])
opts.action = .push
let _ = try await client.navigate(next.last!.url.absoluteString, opts)
} else if next.count == prev.count {
guard let liveChannel = try await self.liveSocket?.navigate(next.last!.url.absoluteString, next.last!.coordinator.liveChannel, NavOptions(action: .replace))
else { return }
next.last?.coordinator.join(liveChannel)
// TODO: this will fire on a patch event! this should not fire on a patch event
var opts = NavOptions()
opts.joinParams = .some([ "_interface": .object(object: LiveSessionParameters.platformParams)])
opts.action = .replace
let _ = try await client.navigate(next.last!.url.absoluteString, opts)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a problem here, core now runs it's own even loop and will take patch and redirect events, these are currently not handled if they are not triggered by a client action. Those events are all pushed through SimpleEventHandler.viewReloadSubject. We need to watch for events that are emitted by server-side events and rectify the navigation path.

the other option is just to call the appropriate client navigation functions wherever we would previously just modify the navigation path.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not following the specific issue here. Can you provide more detail?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

previously the swiftui client listened on the live view channel for live_reload and other push_navigation type events, then it would modify the LiveSessionCoordinator.NavigationPath and all navigation would happen downstream of checking for changes in the scan block like we do here.

Now that push navigation events are handled opaquely inside core, we need to update the LiveSessionCoordinator.NavigationPath in response to internal events. While the code provided here works, the NavigationPath should be controlled by core, which is the opposite of how it works now.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh nice :)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and yes, I agree

}
}
}.store(in: &cancellables)
Expand Down Expand Up @@ -182,31 +221,25 @@ public class LiveSessionCoordinator<R: RootRegistry>: ObservableObject {

let headers = (configuration.headers ?? [:])
.merging(additionalHeaders ?? [:]) { $1 }

self.liveSocket = try await LiveSocket(
originalURL.absoluteString,
LiveSessionParameters.platform,
ConnectOpts(
headers: headers,
body: httpBody.flatMap({ String(data: $0, encoding: .utf8) }),
method: httpMethod.flatMap(Method.init(_:)),
timeoutMs: 10_000
)

let opts = ClientConnectOpts(
joinParams: .some([ "_interface": .object(object: LiveSessionParameters.platformParams)]),
headers: .some(headers),
method: Method.init(httpMethod ?? "Get"),
requestBody: httpBody
)

// save cookies to storage
HTTPCookieStorage.shared.setCookies(
(self.liveSocket!.joinHeaders()["set-cookie"] ?? []).flatMap {
HTTPCookie.cookies(withResponseHeaderFields: ["Set-Cookie": $0], for: URL(string: self.liveSocket!.joinUrl())!)
},
for: self.url,
mainDocumentURL: nil
)
if let client = self.liveviewClient {
try await client.reconnect(originalURL.absoluteString, opts)
} else {
self.liveviewClient = try await self.builder.connect(originalURL.absoluteString, opts)
self.navigationPath.last!.coordinator.join(self.liveviewClient!, self.eventHandler, self.patchHandler)
}


self.socket = self.liveSocket?.socket()

self.rootLayout = self.liveSocket!.deadRender()
let styleURLs = self.liveSocket!.styleUrls()
self.rootLayout = try self.liveviewClient!.deadRender()
let styleURLs = try self.liveviewClient!.styleUrls()

self.stylesheet = try await withThrowingTaskGroup(of: Stylesheet<R>.self) { @Sendable group in
for style in styleURLs {
Expand All @@ -229,49 +262,21 @@ public class LiveSessionCoordinator<R: RootRegistry>: ObservableObject {
}
}

let liveChannel = try await self.liveSocket!.joinLiveviewChannel(
.some([
"_format": .str(string: LiveSessionParameters.platform),
"_interface": .object(object: LiveSessionParameters.platformParams)
]),
nil
)

self.navigationPath.last!.coordinator.join(liveChannel)

self.state = .connected

if self.liveSocket!.hasLiveReload() {
self.liveReloadChannel = try await self.liveSocket!.joinLivereloadChannel()
bindLiveReloadListener()
}
} catch {
self.state = .connectionFailed(error)
}
}

func bindLiveReloadListener() {
let eventListener = self.liveReloadChannel!.channel().eventStream()
self.liveReloadListener = eventListener
self.liveReloadListenerLoop = Task { @MainActor [weak self] in
for try await event in eventListener {
guard let self else { return }
switch event.event {
case .user(user: "assets_change"):
try await self.disconnect()
self.navigationPath = [.init(url: self.url, coordinator: .init(session: self, url: self.url), navigationTransition: nil, pendingView: nil)]
try await self.connect()
default:
continue
}
}
}
}


private func disconnect(preserveNavigationPath: Bool = false) async {
do {
for entry in self.navigationPath {
try await entry.coordinator.disconnect()
entry.coordinator.disconnect()
if !preserveNavigationPath {
entry.coordinator.document = nil
}
Expand All @@ -284,11 +289,10 @@ public class LiveSessionCoordinator<R: RootRegistry>: ObservableObject {

self.navigationPath = [self.navigationPath.first!]
}
try await self.liveReloadChannel?.channel().leave()
self.liveReloadChannel = nil
try await self.socket?.disconnect()
self.socket = nil
self.liveSocket = nil

if let client = self.liveviewClient {
try await client.disconnect()
}
self.state = .disconnected
} catch {
self.state = .connectionFailed(error)
Expand All @@ -306,23 +310,8 @@ public class LiveSessionCoordinator<R: RootRegistry>: ObservableObject {
self.url = url
self.navigationPath = [.init(url: self.url, coordinator: self.navigationPath.first!.coordinator, navigationTransition: nil, pendingView: nil)]
}
try await self.connect(httpMethod: httpMethod, httpBody: httpBody, additionalHeaders: headers)
// do {
// if let url {
// try await self.disconnect(preserveNavigationPath: false)
// self.url = url
// self.navigationPath = [.init(url: self.url, coordinator: self.navigationPath.first!.coordinator, navigationTransition: nil, pendingView: nil)]
// } else {
// // preserve the navigation path, but still clear the stale documents, since they're being completely replaced.
// try await self.disconnect(preserveNavigationPath: true)
// for entry in self.navigationPath {
// entry.coordinator.document = nil
// }
// }
// try await self.connect(httpMethod: httpMethod, httpBody: httpBody, additionalHeaders: headers)
// } catch {
// self.state = .connectionFailed(error)
// }
await self.connect(httpMethod: httpMethod, httpBody: httpBody, additionalHeaders: headers)

}

/// Creates a publisher that can be used to listen for server-sent LiveView events.
Expand Down Expand Up @@ -353,6 +342,22 @@ public class LiveSessionCoordinator<R: RootRegistry>: ObservableObject {
.store(in: &eventHandlers)
}

public func postFormData(
url: Url,
formData: [String: String]
) async throws {



if let client = self.liveviewClient {

try await client.postForm(url.absoluteString,
formData,
.some([ "_interface": .object(object: LiveSessionParameters.platformParams)]),
nil)
}
}

func redirect(
_ redirect: LiveRedirect,
navigationTransition: Any? = nil,
Expand Down Expand Up @@ -536,3 +541,5 @@ fileprivate extension URL {

extension Socket: @unchecked Sendable {}
extension Channel: @unchecked Sendable {}
extension LiveViewClient: @unchecked Sendable {}
extension LiveViewClientBuilder: @unchecked Sendable {}
Loading
Loading