Skip to content

Commit 4a9005d

Browse files
authored
Merge pull request #203 from Alt-iOS/main
Add an optional sync loading mode
2 parents ec26afe + 95b83b8 commit 4a9005d

5 files changed

Lines changed: 167 additions & 12 deletions

File tree

docs/advanced.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ This guide covers URL state management, relationships, embedded resources, refre
1010
- [Collection Refresh](#collection-refresh)
1111
- [Loading, Empty & Error States](#loading-empty-error-states)
1212
- [Performance Optimization](#performance-optimization)
13+
- [Server-rendered Initial Data](#server-rendered-initial-data)
1314
- [Query Access](#query-access)
1415
- [Selection & Bulk Actions](#selection--bulk-actions)
1516

@@ -703,3 +704,29 @@ When `selectable` is enabled without a `click` handler, clicking rows/items togg
703704
...
704705
</Cinder.collection>
705706
```
707+
708+
## Server-rendered initial data
709+
710+
Cinder loads collection data asynchronously by default. To include the initial
711+
rows in the server-rendered HTML, enable SSR for a collection:
712+
713+
```heex
714+
<Cinder.collection resource={MyApp.User} ssr>
715+
<:col :let={user} field="name">{user.name}</:col>
716+
</Cinder.collection>
717+
```
718+
719+
You can enable it globally and override it for individual collections:
720+
721+
```elixir
722+
config :cinder, ssr: true
723+
```
724+
725+
```heex
726+
<Cinder.collection resource={MyApp.AuditLog} ssr={false}>
727+
...
728+
</Cinder.collection>
729+
```
730+
731+
SSR only makes the initial load synchronous. Filtering, sorting, pagination,
732+
and refreshes continue to load asynchronously.

lib/cinder/collection.ex

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,13 @@ defmodule Cinder.Collection do
155155
doc: "Additional Ash query options"
156156
)
157157

158+
attr(:ssr, :boolean,
159+
default: nil,
160+
doc:
161+
"Load initial data synchronously so it is included in the server-rendered HTML. " <>
162+
"Defaults to `config :cinder, ssr: false`; a collection value overrides the global setting."
163+
)
164+
158165
attr(:on_state_change, :any, default: nil, doc: "Custom state change handler")
159166

160167
attr(:on_query_change, :any,
@@ -367,6 +374,13 @@ defmodule Cinder.Collection do
367374
|> assign_new(:theme, fn -> "default" end)
368375
|> assign_new(:url_state, fn -> false end)
369376
|> assign_new(:query_opts, fn -> [] end)
377+
|> assign(
378+
:ssr,
379+
if(is_nil(assigns[:ssr]),
380+
do: Application.get_env(:cinder, :ssr, false),
381+
else: assigns[:ssr]
382+
)
383+
)
370384
|> assign_new(:on_state_change, fn -> nil end)
371385
|> assign_new(:on_query_change, fn -> nil end)
372386
|> assign_new(:show_pagination, fn -> true end)
@@ -482,6 +496,7 @@ defmodule Cinder.Collection do
482496
theme={@resolved_theme}
483497
url_raw_params={get_raw_url_params(@url_state)}
484498
query_opts={@query_opts}
499+
ssr={@ssr}
485500
on_state_change={get_state_change_handler(@url_state, @on_state_change, @id)}
486501
show_filters={@show_filters}
487502
show_sort={@show_sort}

lib/cinder/live_component.ex

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ defmodule Cinder.LiveComponent do
55
This component handles all data management logic:
66
- State management (filters, sorting, pagination)
77
- Event handling (filter_change, toggle_sort, goto_page, etc.)
8-
- Async data loading
8+
- Server-rendered initial data and async subsequent loading
99
- URL state synchronization
1010
1111
The actual HTML rendering is delegated to a renderer module passed via
@@ -853,8 +853,10 @@ defmodule Cinder.LiveComponent do
853853
|> assign(:search_term, assigns[:search_term] || "")
854854
|> assign(:theme, assigns[:theme] || Cinder.Theme.default())
855855
|> assign(:query_opts, assigns[:query_opts] || [])
856+
|> assign(:ssr, Map.get(assigns, :ssr, false))
856857
|> assign_new(:action, fn -> nil end)
857858
|> assign_new(:page, fn -> nil end)
859+
|> assign_new(:__initial_load__, fn -> is_nil(assigns[:page]) end)
858860
|> assign(:user_has_interacted, Map.get(socket.assigns, :user_has_interacted, false))
859861
# Keyset pagination state
860862
|> assign(:pagination_mode, pagination_mode)
@@ -968,7 +970,7 @@ defmodule Cinder.LiveComponent do
968970
defp normalize_scope(value), do: value
969971

970972
defp load_data_if_needed(socket, prev) do
971-
first_load = socket.assigns[:page] == nil
973+
first_load = socket.assigns[:__initial_load__] == true
972974
curr = data_state(socket.assigns)
973975
state_changed = curr != prev
974976
reload_requested = socket.assigns[:__reload_requested__] == true
@@ -982,6 +984,8 @@ defmodule Cinder.LiveComponent do
982984
end
983985

984986
defp load_data(socket) do
987+
initial_load? = socket.assigns[:__initial_load__] == true
988+
985989
%{
986990
query: resource,
987991
query_opts: query_opts,
@@ -1028,13 +1032,17 @@ defmodule Cinder.LiveComponent do
10281032
]
10291033

10301034
socket
1035+
|> assign(:__initial_load__, false)
10311036
|> assign(:loading, true)
10321037
|> assign(:error, false)
10331038
|> then(fn socket ->
10341039
# Build the query once so we can both execute it and hand it to the
10351040
# on_query_change callback (if one is configured). maybe_notify_query_change/2
10361041
# decides whether to actually notify.
1037-
if Application.get_env(:ash, :disable_async?) do
1042+
# With SSR enabled, the first query completes before rendering so both the
1043+
# disconnected HTTP response and connected LiveView mount contain data.
1044+
# Later interactions remain async so they do not block the LiveView process.
1045+
if (initial_load? and socket.assigns.ssr) or Application.get_env(:ash, :disable_async?) do
10381046
try do
10391047
case Cinder.QueryBuilder.build_query(resource_var, options) do
10401048
{:ok, prepared_query} ->

lib/cinder/table.ex

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,11 @@ defmodule Cinder.Table do
5151
attr :theme, :any, default: "default", doc: "Theme name or theme map"
5252
attr :url_state, :any, default: false, doc: "URL state object from UrlSync.handle_params"
5353
attr :query_opts, :list, default: [], doc: "Additional Ash query options"
54+
55+
attr :ssr, :boolean,
56+
default: nil,
57+
doc: "Load initial data in the server-rendered HTML. Overrides `config :cinder, ssr: false`."
58+
5459
attr :on_state_change, :any, default: nil, doc: "Custom state change handler"
5560

5661
attr :on_query_change, :any,
Lines changed: 109 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,63 @@
11
defmodule Cinder.Integration.AsyncLoadTest do
22
@moduledoc """
3-
Covers the async data-loading path end-to-end.
3+
Covers opt-in server-rendered initial data with async loading enabled.
44
55
Every other integration test loads data synchronously (see `Cinder.ConnCase`)
6-
for simplicity. This test opts back into Cinder's default `start_async` loading
7-
to prove the full async cycle works: the view mounts, the async query runs in a
8-
separate task, replies, and the rows appear on re-render.
6+
for simplicity. This test opts back into Cinder's default async mode and
7+
verifies the global and per-collection SSR settings.
98
"""
109
use Cinder.ConnCase, async: false
10+
import Phoenix.ConnTest, only: [get: 2, html_response: 2]
1111

1212
# Opt back into async loading for this test (ConnCase's setup disabled it).
1313
setup {Cinder.TestHelpers, :enable_async_loading}
1414

15-
defp album_collection(assigns) do
15+
defp restore_ssr_config(nil), do: Application.delete_env(:cinder, :ssr)
16+
defp restore_ssr_config(value), do: Application.put_env(:cinder, :ssr, value)
17+
18+
defp ssr_album_collection(assigns) do
19+
~H"""
20+
<Cinder.collection resource={Cinder.Integration.Album} url_state={@url_state} ssr>
21+
<:col :let={album} field="title" sort>{album.title}</:col>
22+
</Cinder.collection>
23+
"""
24+
end
25+
26+
defp default_album_collection(assigns) do
1627
~H"""
1728
<Cinder.collection resource={Cinder.Integration.Album} url_state={@url_state}>
1829
<:col :let={album} field="title" sort>{album.title}</:col>
1930
</Cinder.collection>
2031
"""
2132
end
2233

34+
defp async_album_collection(assigns) do
35+
~H"""
36+
<Cinder.collection resource={Cinder.Integration.Album} url_state={@url_state} ssr={false}>
37+
<:col :let={album} field="title" sort>{album.title}</:col>
38+
</Cinder.collection>
39+
"""
40+
end
41+
42+
defp failing_ssr_assigns do
43+
%{
44+
id: "failing-ssr-collection",
45+
query: Cinder.Integration.Album,
46+
action: :missing_read_action,
47+
actor: nil,
48+
tenant: nil,
49+
page_size_config: Cinder.PageSize.parse(nil),
50+
theme: Cinder.Theme.default(),
51+
url_raw_params: %{},
52+
query_opts: [],
53+
ssr: true,
54+
on_state_change: nil,
55+
col: [],
56+
query_columns: [],
57+
search_fn: nil
58+
}
59+
end
60+
2361
setup do
2462
artist = generate(artist(name: "Async Artist"))
2563
generate(album(title: "Async Album", genre: :rock, artist_id: artist.id))
@@ -29,14 +67,76 @@ defmodule Cinder.Integration.AsyncLoadTest do
2967
Ash.bulk_destroy!(Cinder.Integration.Artist, :destroy, %{})
3068
end)
3169

32-
%{path: Cinder.TestLive.Fixture.register(&album_collection/1)}
70+
%{
71+
ssr_path: Cinder.TestLive.Fixture.register(&ssr_album_collection/1),
72+
default_path: Cinder.TestLive.Fixture.register(&default_album_collection/1),
73+
async_path: Cinder.TestLive.Fixture.register(&async_album_collection/1)
74+
}
75+
end
76+
77+
test "ssr=true includes collection data in the initial HTTP response", %{
78+
conn: conn,
79+
ssr_path: path
80+
} do
81+
html =
82+
conn
83+
|> get(path)
84+
|> html_response(200)
85+
86+
assert html =~ "Async Album"
3387
end
3488

35-
test "rows appear after the async query resolves", %{conn: conn, path: path} do
89+
test "SSR is disabled by default", %{conn: conn, default_path: path} do
90+
html =
91+
conn
92+
|> get(path)
93+
|> html_response(200)
94+
95+
refute html =~ "Async Album"
96+
end
97+
98+
test "the default async load still delivers collection data", %{conn: conn, default_path: path} do
3699
conn
37100
|> visit(path)
38-
# The data is not present on the first synchronous render; it arrives only
39-
# after the start_async task replies, so we wait for it with a timeout.
40101
|> assert_has("td", text: "Async Album", timeout: 1000)
41102
end
103+
104+
test "SSR can be enabled globally", %{conn: conn, default_path: path} do
105+
original = Application.get_env(:cinder, :ssr)
106+
Application.put_env(:cinder, :ssr, true)
107+
on_exit(fn -> restore_ssr_config(original) end)
108+
109+
html =
110+
conn
111+
|> get(path)
112+
|> html_response(200)
113+
114+
assert html =~ "Async Album"
115+
end
116+
117+
test "a collection setting overrides the global setting", %{conn: conn, async_path: path} do
118+
original = Application.get_env(:cinder, :ssr)
119+
Application.put_env(:cinder, :ssr, true)
120+
on_exit(fn -> restore_ssr_config(original) end)
121+
122+
html =
123+
conn
124+
|> get(path)
125+
|> html_response(200)
126+
127+
refute html =~ "Async Album"
128+
end
129+
130+
test "a retry is asynchronous after the initial SSR query fails" do
131+
{:ok, socket} = Cinder.LiveComponent.mount(%Phoenix.LiveView.Socket{})
132+
{:ok, socket} = Cinder.LiveComponent.update(failing_ssr_assigns(), socket)
133+
134+
assert socket.assigns.error
135+
assert socket.assigns.page == nil
136+
137+
{:noreply, socket} = Cinder.LiveComponent.handle_event("refresh", %{}, socket)
138+
139+
assert socket.assigns.loading
140+
refute socket.assigns.error
141+
end
42142
end

0 commit comments

Comments
 (0)