Skip to content

Commit aebc7fa

Browse files
nbudinclaude
andcommitted
Show a loading spinner throughout the entire app boot sequence
- Inline CSS spinner in the HTML shell so something appears immediately, even before the JS bundle downloads - RouterLoadingOverlay component that stays visible until router.state.initialized is true, bridging the gap between the bootstrap Suspense phase and route components rendering - app_root_loading_content helper in ApplicationHelper (included by both the ERB layout and CmsRenderingContext) so the inline spinner is rendered consistently in both paths Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 41de042 commit aebc7fa

4 files changed

Lines changed: 49 additions & 3 deletions

File tree

app/helpers/application_helper.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,21 @@ def url_with_possible_host(path, host)
3131
"#{request.scheme}://#{host}#{path}"
3232
end
3333

34+
def app_root_loading_content
35+
visually_hidden_style =
36+
"position:absolute;width:1px;height:1px;padding:0;margin:-1px;" \
37+
"overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0"
38+
spinner_style =
39+
"display:inline-block;width:2rem;height:2rem;border:0.25em solid currentColor;" \
40+
"border-right-color:transparent;border-radius:50%;animation:_intercode_spinner 0.75s linear infinite"
41+
42+
tag.style("@keyframes _intercode_spinner{to{transform:rotate(360deg)}}") +
43+
tag.div(
44+
tag.div(tag.span("Loading...", style: visually_hidden_style), role: "status", style: spinner_style),
45+
style: "text-align:center;margin-top:3rem"
46+
)
47+
end
48+
3449
def browser_warning
3550
# CloudFront strips all cookies before forwarding to the origin, so the
3651
# suppressBrowserWarning cookie is never visible to Rails on proxied requests.

app/javascript/packs/application.tsx

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import 'regenerator-runtime/runtime';
22

33
import mountReactComponents from '../mountReactComponents';
4-
import { StrictMode, Suspense, use, useMemo } from 'react';
4+
import { StrictMode, Suspense, use, useLayoutEffect, useMemo, useState } from 'react';
55
import AuthenticityTokensManager, { getAuthenticityTokensURL } from 'AuthenticityTokensContext';
66
import { createBrowserRouter, RouterContextProvider, RouterProvider } from 'react-router';
77
import { buildBrowserApolloClient, GraphQLNotAuthenticatedErrorEvent } from 'useIntercodeApolloClient';
@@ -70,6 +70,35 @@ const bootstrapPromise: Promise<Bootstrap> = (async () => {
7070
return { clientConfiguration, authenticityTokensManager, authManager, client };
7171
})();
7272

73+
type BrowserRouter = ReturnType<typeof createBrowserRouter>;
74+
75+
// createBrowserRouter calls router.initialize() internally, which starts the
76+
// initial navigation asynchronously. By the time our useEffect subscribes,
77+
// the navigation may already be done — router.subscribe fires the callback
78+
// synchronously (via a buffered state update) with the final state. Watching
79+
// router.state.initialized (set to true once initial data loading completes)
80+
// handles both "already done" and "still in progress" correctly.
81+
function RouterLoadingOverlay({ router }: { router: BrowserRouter }) {
82+
const [ready, setReady] = useState(() => router.state.initialized);
83+
84+
useLayoutEffect(() => {
85+
return router.subscribe((state) => {
86+
if (state.initialized) {
87+
setReady(true);
88+
}
89+
});
90+
}, [router]);
91+
92+
if (ready) return null;
93+
94+
return (
95+
<div className="text-center mt-5 custom-loading-indicator">
96+
<div className="spinner-border" role="status" />
97+
<span className="visually-hidden">Loading...</span>
98+
</div>
99+
);
100+
}
101+
73102
function DataModeApplicationEntry() {
74103
const bootstrap = use(bootstrapPromise);
75104

@@ -101,6 +130,7 @@ function DataModeApplicationEntry() {
101130
return (
102131
<StrictMode>
103132
<AuthenticationManagerContext.Provider value={bootstrap.authManager}>
133+
<RouterLoadingOverlay router={router} />
104134
<RouterProvider router={router} />
105135
</AuthenticationManagerContext.Provider>
106136
</StrictMode>

app/presenters/cms_rendering_context.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,8 @@ def render_app_root_content(cms_layout, assigns)
6565
doc = Nokogiri::HTML.parse("<!DOCTYPE html><html><head>#{assigns["content_for_head"]}</head><body></body></html>")
6666
doc.xpath("//body/*").remove
6767
doc.xpath("//body").first.inner_html =
68-
(assigns["browser_warning"] || "") + NOSCRIPT_WARNING + tag.div("", "data-react-class" => "AppRoot")
68+
(assigns["browser_warning"] || "") + NOSCRIPT_WARNING +
69+
tag.div(app_root_loading_content, "data-react-class" => "AppRoot")
6970
doc.to_s.html_safe # rubocop:disable Rails/OutputSafety
7071
rescue StandardError => e
7172
ErrorReporting.warn(e)

app/views/layouts/cdn_spa_shell.html.erb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,6 @@
2828
</head>
2929
<body>
3030
<%= CmsRenderingContext::NOSCRIPT_WARNING %>
31-
<div data-react-class="AppRoot"></div>
31+
<div data-react-class="AppRoot"><%= app_root_loading_content %></div>
3232
</body>
3333
</html>

0 commit comments

Comments
 (0)