Skip to content

AsyncContent #27

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

Merged
merged 5 commits into from
Sep 13, 2024
Merged
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
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,26 @@ div {

As a sensible default, _class_ and _style_ attributes are merged (with a blank space or semicolon respectively). All other attributes are overwritten by default.

## Fist class async support

Elementary supports Swift Concurrency in HTML content. Simply `await` something needed for rendering, while the first bytes are already flying towards the browser.

```swift
div {
let text = await getMyData()
p { "This totally works: \(text)" }
MyComponent()
}

struct MyComponent: HTML {
var content: some HTML {
AsyncContent {
"So does this: \(await getMoreData())"
}
}
}
```

### 🚧 Work in progress 🚧

The list of built-in attributes is rather short still, but adding them is really simple (and can be done in external packages as well).
Expand All @@ -202,5 +222,4 @@ My main motivation for Elementary was to create an experience like these ([Swift

## Future directions

- Experiment with an `AsyncHTML` type, that can include `await` in bodies, and a `ForEach` type that takes an async sequence
- Experiment with embedded swift for wasm and bolt a lean state tracking/reconciler for reactive DOM manipulation on top
34 changes: 34 additions & 0 deletions Sources/Elementary/Core/AsyncContent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/// An element that awaits its content before rendering.
///
/// The this element can only be rendered in an async context (ie: by calling ``HTML/render(into:chunkSize:)`` or ``HTML/renderAsync()``).
/// All HTML tag types (``HTMLElement``) support async content closures in their initializers, so you don't need to use this element directly in most cases.
public struct AsyncContent<Content: HTML>: HTML, Sendable {
var content: @Sendable () async throws -> Content
public typealias Tag = Content.Tag

/// Creates a new async HTML element with the specified content.
///
/// - Parameters:
/// - content: The future content of the element.
///
/// ```swift
/// AsyncContent {
/// let value = await fetchValue()
/// "Waiting for "
/// span { value }
/// }
/// ```
public init(@HTMLBuilder content: @escaping @Sendable () async throws -> Content) {
self.content = content
}

@_spi(Rendering)
public static func _render<Renderer: _HTMLRendering>(_ html: consuming Self, into renderer: inout Renderer, with context: consuming _RenderingContext) {
context.assertionFailureNoAsyncContext(self)
}

@_spi(Rendering)
public static func _render<Renderer: _AsyncHTMLRendering>(_ html: consuming Self, into renderer: inout Renderer, with context: consuming _RenderingContext) async throws {
try await Content._render(await html.content(), into: &renderer, with: context)
}
}
2 changes: 1 addition & 1 deletion Sources/Elementary/Core/ForEach.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,4 @@ public struct ForEach<Data, Content>: HTML
}
}

extension ForEach: Sendable where Data: Sendable, Content: Sendable {}
extension ForEach: Sendable where Data: Sendable {}
49 changes: 0 additions & 49 deletions Sources/Elementary/Core/Html+Attributes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,55 +61,6 @@ public struct _AttributedElement<Content: HTML>: HTML {

extension _AttributedElement: Sendable where Content: Sendable {}

public extension HTMLElement {
/// Creates a new HTML element with the specified attribute and content.
/// - Parameters:
/// - attribute: The attribute to apply to the element.
/// - content: The content of the element.
init(_ attribute: HTMLAttribute<Tag>, @HTMLBuilder content: () -> Content) {
attributes = .init(attribute)
self.content = content()
}

/// Creates a new HTML element with the specified attributes and content.
/// - Parameters:
/// - attributes: The attributes to apply to the element.
/// - content: The content of the element.
init(_ attributes: HTMLAttribute<Tag>..., @HTMLBuilder content: () -> Content) {
self.attributes = .init(attributes)
self.content = content()
}

/// Creates a new HTML element with the specified attributes and content.
/// - Parameters:
/// - attributes: The attributes to apply to the element as an array.
/// - content: The content of the element.
init(attributes: [HTMLAttribute<Tag>], @HTMLBuilder content: () -> Content) {
self.attributes = .init(attributes)
self.content = content()
}
}

public extension HTMLVoidElement {
/// Creates a new HTML void element with the specified attribute.
/// - Parameter attribute: The attribute to apply to the element.
init(_ attribute: HTMLAttribute<Tag>) {
attributes = .init(attribute)
}

/// Creates a new HTML void element with the specified attributes.
/// - Parameter attributes: The attributes to apply to the element.
init(_ attributes: HTMLAttribute<Tag>...) {
self.attributes = .init(attributes)
}

/// Creates a new HTML void element with the specified attributes.
/// - Parameter attributes: The attributes to apply to the element as an array.
init(attributes: [HTMLAttribute<Tag>]) {
self.attributes = .init(attributes)
}
}

public extension HTML where Tag: HTMLTrait.Attributes.Global {
/// Adds the specified attribute to the element.
/// - Parameters:
Expand Down
45 changes: 45 additions & 0 deletions Sources/Elementary/Core/Html+Elements.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,33 @@ public struct HTMLElement<Tag: HTMLTagDefinition, Content: HTML>: HTML where Tag
self.content = content()
}

/// Creates a new HTML element with the specified attribute and content.
/// - Parameters:
/// - attribute: The attribute to apply to the element.
/// - content: The content of the element.
public init(_ attribute: HTMLAttribute<Tag>, @HTMLBuilder content: () -> Content) {
attributes = .init(attribute)
self.content = content()
}

/// Creates a new HTML element with the specified attributes and content.
/// - Parameters:
/// - attributes: The attributes to apply to the element.
/// - content: The content of the element.
public init(_ attributes: HTMLAttribute<Tag>..., @HTMLBuilder content: () -> Content) {
self.attributes = .init(attributes)
self.content = content()
}

/// Creates a new HTML element with the specified attributes and content.
/// - Parameters:
/// - attributes: The attributes to apply to the element as an array.
/// - content: The content of the element.
public init(attributes: [HTMLAttribute<Tag>], @HTMLBuilder content: () -> Content) {
self.attributes = .init(attributes)
self.content = content()
}

@_spi(Rendering)
public static func _render<Renderer: _HTMLRendering>(_ html: consuming Self, into renderer: inout Renderer, with context: consuming _RenderingContext) {
html.attributes.append(context.attributes)
Expand Down Expand Up @@ -42,6 +69,24 @@ public struct HTMLVoidElement<Tag: HTMLTagDefinition>: HTML where Tag: HTMLTrait
attributes = .init()
}

/// Creates a new HTML void element with the specified attribute.
/// - Parameter attribute: The attribute to apply to the element.
public init(_ attribute: HTMLAttribute<Tag>) {
attributes = .init(attribute)
}

/// Creates a new HTML void element with the specified attributes.
/// - Parameter attributes: The attributes to apply to the element.
public init(_ attributes: HTMLAttribute<Tag>...) {
self.attributes = .init(attributes)
}

/// Creates a new HTML void element with the specified attributes.
/// - Parameter attributes: The attributes to apply to the element as an array.
public init(attributes: [HTMLAttribute<Tag>]) {
self.attributes = .init(attributes)
}

@_spi(Rendering)
public static func _render<Renderer: _HTMLRendering>(_ html: consuming Self, into renderer: inout Renderer, with context: consuming _RenderingContext) {
html.attributes.append(context.attributes)
Expand Down
29 changes: 29 additions & 0 deletions Sources/Elementary/Core/HtmlElement+Async.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
public extension HTMLElement {
/// Creates a new HTML element with the specified tag and async content.
///
/// The async content closure is automatically wrapped in an ``AsyncContent`` element and can only be rendered in an async context.
///
/// - Parameters:
/// - attributes: The attributes to apply to the element.
/// - content: The future content of the element.
init<AwaitedContent: HTML>(_ attributes: HTMLAttribute<Tag>..., @HTMLBuilder content: @escaping @Sendable () async throws -> AwaitedContent)
where Self.Content == AsyncContent<AwaitedContent>
{
self.attributes = .init(attributes)
self.content = AsyncContent(content: content)
}

/// Creates a new HTML element with the specified tag and async content.
///
/// The async content closure is automatically wrapped in an ``AsyncContent`` element and can only be rendered in an async context.
///
/// - Parameters:
/// - attributes: The attributes to apply to the element.
/// - content: The future content of the element.
init<AwaitedContent: HTML>(attributes: [HTMLAttribute<Tag>], @HTMLBuilder content: @escaping @Sendable () async throws -> AwaitedContent)
where Self.Content == AsyncContent<AwaitedContent>
{
self.attributes = .init(attributes)
self.content = AsyncContent(content: content)
}
}
2 changes: 1 addition & 1 deletion Sources/Elementary/HtmlDocument.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/// A type that represents a full HTML document.
///
/// Provides a simple structure to model top-level HTML types.
/// A default ``content`` implementation takes your ``title``, ``head`` and ``body``
/// A default ``HTML/content`` implementation takes your ``title``, ``head`` and ``body``
/// properties and renders them into a full HTML document.
///
/// ```swift
Expand Down
7 changes: 7 additions & 0 deletions Sources/Elementary/Rendering/RenderingUtils.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ extension _RenderingContext {
func assertNoAttributes(_ type: (some HTML).Type) {
assert(attributes.isEmpty, "Attributes are not supported on \(type)")
}

@inline(__always)
func assertionFailureNoAsyncContext(_ type: (some HTML).Type) {
let message = "Cannot render \(type) in a synchronous context, please use .render(into:) or .renderAsync() instead."
print("Elementary rendering error: \(message)")
assertionFailure(message)
}
}

// NOTE: weirdly, using string interpolation tokens is faster than appending to the buffer directly. keeping this here for future experiments
Expand Down
66 changes: 66 additions & 0 deletions Tests/ElementaryTests/AsyncRenderingTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import Elementary
import XCTest

final class AsyncRenderingTests: XCTestCase {
func testRendersAsyncContent() async throws {
try await HTMLAssertEqualAsyncOnly(
AsyncContent {
let text = await getValue()
"Waiting for "
span { text }
},
"Waiting for <span>late response</span>"
)
}

func testAsyncElementInTuple() async throws {
try await HTMLAssertEqualAsyncOnly(
div {
AwaitedP(number: 1)
AwaitedP(number: 2)
AwaitedP(number: 3)
},
"<div><p>1</p><p>2</p><p>3</p></div>"
)
}

func testImplicitlyAsyncContent() async throws {
try await HTMLAssertEqualAsyncOnly(
p(.id("hello")) {
let text = await getValue()
"Waiting for \(text)"
},
#"<p id="hello">Waiting for late response</p>"#
)
}

func testNestedImplicitAsyncContent() async throws {
try await HTMLAssertEqualAsyncOnly(
div(attributes: [.class("c1")]) {
p {
await getValue()
}
"again \(await getValue())"
p(.class("c2")) {
"and again \(await getValue())"
}
},
#"<div class="c1"><p>late response</p>again late response<p class="c2">and again late response</p></div>"#
)
}
}

private struct AwaitedP: HTML {
var number: Int
var content: some HTML {
AsyncContent {
let _ = try await Task.sleep(for: .milliseconds(1))
p { "\(number)" }
}
}
}

private func getValue() async -> String {
await Task.yield() // just for fun
return "late response"
}
4 changes: 4 additions & 0 deletions Tests/ElementaryTests/Utilities.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import XCTest
func HTMLAssertEqual(_ html: some HTML, _ expected: String, file: StaticString = #filePath, line: UInt = #line) async throws {
XCTAssertEqual(expected, html.render(), file: file, line: line)

try await HTMLAssertEqualAsyncOnly(html, expected, file: file, line: line)
}

func HTMLAssertEqualAsyncOnly(_ html: some HTML, _ expected: String, file: StaticString = #filePath, line: UInt = #line) async throws {
let asyncText = try await html.renderAsync()
XCTAssertEqual(expected, asyncText, file: file, line: line)
}
Expand Down