Skip to content

Commit a6da5ba

Browse files
authored
Prerender functions (#102)
* Added prerender method * added prerenderAround function * Review fixes
1 parent a0e35c7 commit a6da5ba

5 files changed

Lines changed: 357 additions & 16 deletions

File tree

src/Oxpecker.ViewEngine/Builder.fs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,9 +106,86 @@ module Builder =
106106
interface HtmlElement with
107107
member this.Render sb = this.Render sb
108108

109+
/// Node with a prerendered prefix and suffix around its children
110+
type PrerenderedNode(prefix: string, suffix: string) =
111+
let mutable children: CustomQueue<HtmlElement> = Unchecked.defaultof<_>
112+
member this.Children = children.AsEnumerable()
113+
member this.AddChild(element: HtmlElement) = children.Enqueue(element)
114+
member this.Render(sb: StringBuilder) =
115+
sb.Append(prefix) |> ignore
116+
RenderHelpers.renderChildren sb children
117+
sb.Append(suffix) |> ignore
118+
interface HtmlContainer with
119+
member this.Render sb = this.Render sb
120+
member this.AddChild element = this.AddChild element
121+
122+
/// Placeholder that records where the dynamic part of a template begins
123+
type internal HoleMarker() =
124+
let mutable buffer: StringBuilder | null = null
125+
let mutable position = -1
126+
let mutable count = 0
127+
member this.Position = position
128+
/// Forgets renders that happened while the template was being built
129+
member this.Reset() =
130+
buffer <- null
131+
position <- -1
132+
count <- 0
133+
/// True only when the hole was rendered exactly once, into this very buffer
134+
member this.WasRenderedOnceInto(sb: StringBuilder) =
135+
count = 1 && obj.ReferenceEquals(buffer, sb)
136+
member this.Render(sb: StringBuilder) =
137+
buffer <- sb
138+
position <- sb.Length
139+
count <- count + 1
140+
interface HtmlElement with
141+
member this.Render sb = this.Render sb
142+
109143
/// Create text node that will NOT be HTML-escaped
110144
let inline raw text = RawTextNode text
111145

146+
/// <summary>
147+
/// Renders an element together with all its children into a static HTML snapshot.
148+
/// Use it to render static parts of a view once, instead of re-rendering them on every request.
149+
/// </summary>
150+
/// <remarks>
151+
/// The snapshot is taken eagerly, at the moment of the call. Children or attributes added
152+
/// to the original element afterwards will not be reflected in the returned node.
153+
/// </remarks>
154+
let prerender (view: #HtmlElement) =
155+
let sb = StringBuilderPool.Get()
156+
try
157+
view.Render sb
158+
RawTextNode(sb.ToString())
159+
finally
160+
StringBuilderPool.Return(sb)
161+
162+
/// <summary>
163+
/// Renders the static part of a template with a hole in it once, and returns a factory
164+
/// creating nodes that fill the hole. Use it for layouts, where only a small part of the
165+
/// markup changes between renders.
166+
/// </summary>
167+
/// <param name="template">
168+
/// Function that receives the hole and places it inside the markup. It has to use the hole exactly once.
169+
/// </param>
170+
/// <remarks>
171+
/// The static part is rendered eagerly, at the moment of the call, exactly like <c>prerender</c> does.
172+
/// </remarks>
173+
let prerenderAround (template: HtmlElement -> #HtmlElement) =
174+
let hole = HoleMarker()
175+
let view = template hole
176+
let sb = StringBuilderPool.Get()
177+
let prefix, suffix =
178+
try
179+
// the template may have rendered the hole into a buffer of its own while being built
180+
hole.Reset()
181+
view.Render sb
182+
if not(hole.WasRenderedOnceInto sb) then
183+
invalidArg (nameof template) "Template has to render the provided hole exactly once"
184+
sb.ToString(0, hole.Position), sb.ToString(hole.Position, sb.Length - hole.Position)
185+
finally
186+
StringBuilderPool.Return(sb)
187+
fun () -> PrerenderedNode(prefix, suffix)
188+
112189
type HtmlContainerFun = HtmlContainer -> unit
113190

114191
// builder methods

src/Oxpecker.ViewEngine/Oxpecker.ViewEngine.fsproj

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@
2222
<PackageReadmeFile>README.md</PackageReadmeFile>
2323
<IncludeSymbols>true</IncludeSymbols>
2424
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
25-
<Version>2.0.1</Version>
26-
<PackageVersion>2.0.1</PackageVersion>
27-
<PackageReleaseNotes>Included documentation file in the package</PackageReleaseNotes>
25+
<Version>2.1.0</Version>
26+
<PackageVersion>2.1.0</PackageVersion>
27+
<PackageReleaseNotes>Added prerender and prerenderAround functions</PackageReleaseNotes>
2828
</PropertyGroup>
2929
<ItemGroup>
3030
<PackageReference Include="FSharp.Core" Version="10.0.100" />

src/Oxpecker.ViewEngine/README.md

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ let mainView (model: Person) =
4444
- [Attributes](#attributes)
4545
- [Event handlers](#event-handlers)
4646
- [Html escaping](#html-escaping)
47+
- [Prerendering](#prerendering)
4748
- [Rendering](#rendering)
4849
- [ARIA](#aria)
4950
- [Fragments](#fragments)
@@ -63,7 +64,7 @@ let mainView (model: Person) =
6364
abstract member AddChild: HtmlElement -> unit
6465
...
6566
```
66-
There are 5 types of HTML elements available: `RegularNode`, `VoidNode` (only attributes), `FragmentNode` (only children), `RegularTextNode`(escaped text), `RawTextNode`(unescaped text).
67+
There are 7 types of HTML elements available: `RegularNode`, `VoidNode` (only attributes), `FragmentNode` (only children), `RegularTextNode`(escaped text), `RawTextNode`(unescaped text), `IntNode`(integer), `PrerenderedNode`(prerendered markup around children).
6768

6869
All HTML tags inherit from `RegularNode` or `VoidNode` and you can easily create your own tag:
6970

@@ -140,6 +141,55 @@ div(){
140141
}
141142
```
142143

144+
### Prerendering
145+
146+
Views are object trees that are walked (and their text and attributes escaped) on every render. When a part of your view is static, `prerender` lets you pay that cost once: it renders an element **together with all its children** into a snapshot that is appended as a plain string on every subsequent render.
147+
148+
```fsharp
149+
// rendered once, when the module is initialized
150+
let pageHeader =
151+
prerender(
152+
header() {
153+
h1() { "My site" }
154+
nav() { a(href = "/") { "Home" } }
155+
}
156+
)
157+
158+
let page (model: Model) =
159+
html() {
160+
body() {
161+
pageHeader // appended as a plain string on every request
162+
main() { model.Content }
163+
}
164+
}
165+
```
166+
167+
`prerender` returns a `RawTextNode` holding already-escaped HTML, so the snapshot is not escaped again when embedded.
168+
169+
Note that the snapshot is taken **eagerly**, at the moment of the call: children or attributes added to the original element afterwards won't be reflected in the returned node.
170+
171+
When only a small part of the markup changes between renders, `prerenderAround` lets you prerender everything around it. It takes a function that places the provided _hole_ inside your markup, renders the static part once, and gives you back a factory that is used like any other tag:
172+
173+
```fsharp
174+
let layout =
175+
prerenderAround(fun content ->
176+
html() {
177+
body() {
178+
header() { h1() { "My site" } }
179+
main() { content }
180+
footer() { "(c) 2026" }
181+
}
182+
})
183+
184+
let page (model: Model) =
185+
layout() {
186+
h2() { model.Title }
187+
p() { model.Text }
188+
}
189+
```
190+
191+
Everything outside the hole is rendered once, so every `page` call only appends two prerendered strings around its own children. The hole has to be used exactly once, otherwise `prerenderAround` raises an `ArgumentException`.
192+
143193
### Rendering
144194

145195
There are several functions to render `HtmlElement` (after opening Oxpecker.ViewEngine namespace):

tests/Oxpecker.ViewEngine.Tests/Render.Tests.fs

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,3 +163,187 @@ let ``Render to text writer`` () =
163163
|> Encoding.UTF8.GetString
164164
|> shouldEqual $"""<!DOCTYPE html>{Environment.NewLine}<html><div id="1"></div></html>"""
165165
}
166+
167+
[<Fact>]
168+
let ``Prerender renders the same as the original element`` () =
169+
let view =
170+
div(id = "1") {
171+
span(class' = "a") { "Hello" }
172+
br()
173+
}
174+
let expected = view |> Render.toString
175+
prerender view |> Render.toString |> shouldEqual expected
176+
177+
[<Fact>]
178+
let ``Prerender includes all children`` () =
179+
let result =
180+
html() {
181+
div(id = "1") { 1 }
182+
div(id = "2") {
183+
div(id = "3", class' = "test")
184+
br()
185+
ul() { yield! [ li() { "one" }; li() { "two" } ] }
186+
}
187+
}
188+
|> prerender
189+
result
190+
|> Render.toString
191+
|> shouldEqual
192+
"""<html><div id="1">1</div><div id="2"><div id="3" class="test"></div><br><ul><li>one</li><li>two</li></ul></div></html>"""
193+
194+
[<Fact>]
195+
let ``Prerendered node is not escaped again when embedded`` () =
196+
let prerendered =
197+
p(id = "<br>") {
198+
raw "<hr>"
199+
span() { "<hr>" }
200+
}
201+
|> prerender
202+
let result = div() { prerendered }
203+
result
204+
|> Render.toString
205+
|> shouldEqual """<div><p id="&lt;br&gt;"><hr><span>&lt;hr&gt;</span></p></div>"""
206+
207+
[<Fact>]
208+
let ``Prerendered node is embedded without a wrapper`` () =
209+
let header = h1() { "My site" } |> prerender
210+
let result = html() { body() { header } }
211+
result
212+
|> Render.toString
213+
|> shouldEqual """<html><body><h1>My site</h1></body></html>"""
214+
215+
[<Fact>]
216+
let ``Prerender of a fragment renders children only`` () =
217+
let result =
218+
Fragment() {
219+
span() { "one" }
220+
span() { "two" }
221+
}
222+
|> prerender
223+
result |> Render.toString |> shouldEqual """<span>one</span><span>two</span>"""
224+
225+
[<Fact>]
226+
let ``Prerender takes an eager snapshot`` () =
227+
let view = div() { span() { "early" } }
228+
let prerendered = prerender view
229+
view.AddChild(span() { "late" })
230+
prerendered
231+
|> Render.toString
232+
|> shouldEqual """<div><span>early</span></div>"""
233+
view
234+
|> Render.toString
235+
|> shouldEqual """<div><span>early</span><span>late</span></div>"""
236+
237+
[<Fact>]
238+
let ``Double render of a prerendered node works`` () =
239+
let prerendered = prerender(span(id = "test1") { "test2" })
240+
let result1 = prerendered |> Render.toString
241+
let result2 = prerendered |> Render.toString
242+
result1 |> shouldEqual """<span id="test1">test2</span>"""
243+
result2 |> shouldEqual """<span id="test1">test2</span>"""
244+
245+
[<Fact>]
246+
let ``Prerendered template renders the same as the original view`` () =
247+
let layout =
248+
prerenderAround(fun content ->
249+
html() {
250+
body() {
251+
h1() { "My site" }
252+
main() { content }
253+
}
254+
})
255+
let expected =
256+
html() {
257+
body() {
258+
h1() { "My site" }
259+
main() { p() { "Dynamic" } }
260+
}
261+
}
262+
|> Render.toString
263+
layout() { p() { "Dynamic" } } |> Render.toString |> shouldEqual expected
264+
265+
[<Fact>]
266+
let ``Prerendered template accepts several children`` () =
267+
let layout = prerenderAround(fun content -> div(id = "wrap") { content })
268+
layout() {
269+
span() { "one" }
270+
span() { "two" }
271+
}
272+
|> Render.toString
273+
|> shouldEqual """<div id="wrap"><span>one</span><span>two</span></div>"""
274+
275+
[<Fact>]
276+
let ``Prerendered template can be filled more than once`` () =
277+
let layout = prerenderAround(fun content -> div() { content })
278+
let first = layout() { "one" }
279+
let second = layout() { "two" }
280+
first |> Render.toString |> shouldEqual """<div>one</div>"""
281+
second |> Render.toString |> shouldEqual """<div>two</div>"""
282+
283+
[<Fact>]
284+
let ``Prerendered template with an unfilled hole`` () =
285+
let layout = prerenderAround(fun content -> div() { content })
286+
layout() |> Render.toString |> shouldEqual """<div></div>"""
287+
288+
[<Fact>]
289+
let ``Prerendered template still escapes the hole content`` () =
290+
let layout = prerenderAround(fun content -> p(id = "<br>") { content })
291+
layout() { "<hr>" }
292+
|> Render.toString
293+
|> shouldEqual """<p id="&lt;br&gt;">&lt;hr&gt;</p>"""
294+
295+
[<Fact>]
296+
let ``Prerendered template with an empty prefix and suffix`` () =
297+
let layout = prerenderAround(fun content -> Fragment() { content })
298+
layout() { span() { "only" } }
299+
|> Render.toString
300+
|> shouldEqual """<span>only</span>"""
301+
302+
[<Fact>]
303+
let ``Prerendered template hole accepts a for loop`` () =
304+
let layout = prerenderAround(fun content -> ul() { content })
305+
layout() {
306+
for i in 1..3 do
307+
li() { i }
308+
}
309+
|> Render.toString
310+
|> shouldEqual """<ul><li>1</li><li>2</li><li>3</li></ul>"""
311+
312+
[<Fact>]
313+
let ``Prerendered templates can be nested`` () =
314+
let outer = prerenderAround(fun content -> html() { body() { content } })
315+
let inner = prerenderAround(fun content -> main(class' = "c") { content })
316+
outer() { inner() { p() { "text" } } }
317+
|> Render.toString
318+
|> shouldEqual """<html><body><main class="c"><p>text</p></main></body></html>"""
319+
320+
[<Fact>]
321+
let ``Prerendered template requires the hole to be used`` () =
322+
Assert.Throws<ArgumentException>(fun () -> prerenderAround(fun _ -> div() { "no hole" }) |> ignore)
323+
|> ignore
324+
325+
[<Fact>]
326+
let ``Prerendered template rejects a hole used twice`` () =
327+
Assert.Throws<ArgumentException>(fun () ->
328+
prerenderAround(fun content ->
329+
div() {
330+
content
331+
content
332+
})
333+
|> ignore)
334+
|> ignore
335+
336+
[<Fact>]
337+
let ``Prerendered template rejects a hole rendered into another buffer`` () =
338+
Assert.Throws<ArgumentException>(fun () -> prerenderAround(fun content -> div() { prerender content }) |> ignore)
339+
|> ignore
340+
341+
[<Fact>]
342+
let ``Prerendered template allows the hole to be rendered elsewhere first`` () =
343+
let layout =
344+
prerenderAround(fun content ->
345+
let _ = Render.toString content
346+
div() { content })
347+
layout() { "dynamic" }
348+
|> Render.toString
349+
|> shouldEqual """<div>dynamic</div>"""

0 commit comments

Comments
 (0)