Skip to content

Commit e54651d

Browse files
rsbhclaude
andauthored
fix: render full page content and styles without JavaScript (#166)
* fix: render full page content and styles without JavaScript SSR pages were unusable with JavaScript disabled for two reasons: 1. renderToReadableStream emits Suspense content as hidden divs revealed by inline $RC scripts. Even after switching to prerender, React still "outlines" completed boundaries larger than progressiveChunkSize (12,800 bytes default) into the same hidden-div mechanism — and every docs page exceeds that. Use prerender with a MAX_SAFE_INTEGER progressiveChunkSize so all resolved content is inlined in place. 2. Page components are React.lazy chunks, so their CSS was only injected by Vite when the JS chunk loaded — never linked in the SSR head. Merge the active route's page-module assets (?assets=client) into the head so pages are fully styled before hydration and without JS. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: pin SSR no-JS rendering contract Contract tests for the prerender strategy in entry-server: large and nested lazy Suspense content must be inlined in place (no hidden divs, no $RC reveal scripts), plus a control test documenting that react-dom's default progressiveChunkSize would still outline large boundaries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: single client stylesheet and no-cache HTML for reliable styling Three related asset fixes: - cssCodeSplit: false for the client build. Per-chunk CSS splitting shipped Apsara's 208KB base CSS twice (entry + shared chunk) and the late copy overrode theme styles at equal specificity. One stylesheet also guarantees lazy page chunks are styled without JavaScript. - Skip serverAssets CSS in production: the SSR build's per-chunk CSS duplicated the client bundle and linked after theme styles. Dev still merges it, where client CSS is collected per-module. - Cache-Control: no-cache on SSR HTML so browsers revalidate and never reference deleted content-hashed assets after a redeploy. Apsara CSS imports move from App.tsx to the entries, which own the base stylesheet ordering. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: theme sidebar styles must outrank Apsara base styles Bundler CSS concatenation order is not guaranteed (rolldown emits neither import order nor its reverse), so theme overrides of Apsara component styles cannot rely on cascade order. Bump the sidebar, header, and main selectors with a .layout ancestor so they win on specificity: sticky full-height sidebar, space-between header without bottom padding, intended sidebar padding, and a sticky breadcrumb subnav. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: retry first page load while dev SSR environment warms up The health endpoint responds before Vite's SSR environment finishes initializing in dev mode, so the smoke test's first page load could hit a 503 and fail immediately (flaked on macOS runners). Retry non-200 responses within the existing first-load budget. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci: cap smoke-test retries to the remaining first-load budget Review follow-ups: pass the remaining budget to each curl attempt so a hung request cannot exceed the advertised FIRST_LOAD_BUDGET, cap the retry sleep the same way, and initialize status so a zero budget still reaches the fail() message under set -u. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 815b9ca commit e54651d

8 files changed

Lines changed: 208 additions & 14 deletions

File tree

‎packages/chronicle/src/server/App.tsx‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import '@raystack/apsara/normalize.css';
2-
import '@raystack/apsara/style.css';
31
import { ThemeProvider, Skeleton, Flex } from '@raystack/apsara';
42
import { lazy, Suspense } from 'react';
53
import { Navigate, useLocation } from 'react-router';

‎packages/chronicle/src/server/entry-client.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
import '@vitejs/plugin-react/preamble';
2+
// Apsara CSS is imported in the entry (not App.tsx) so it lands only in the
3+
// entry chunk's CSS, which links FIRST — theme CSS must cascade over it.
4+
// Importing it from App.tsx bundled a duplicate copy into the shared index
5+
// chunk, which links last and overrode theme styles.
6+
import '@raystack/apsara/normalize.css';
7+
import '@raystack/apsara/style.css';
28
import React from 'react';
39
import { hydrateRoot } from 'react-dom/client';
410
import { BrowserRouter } from 'react-router';

‎packages/chronicle/src/server/entry-server.tsx‎

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import '@raystack/apsara/normalize.css';
22
import '@raystack/apsara/style.css';
33
import React from 'react';
4-
import { renderToReadableStream } from 'react-dom/server.edge';
4+
import { prerender } from 'react-dom/static.edge';
55
import { StaticRouter } from 'react-router';
66
import { ReactRouterProvider } from 'fumadocs-core/framework/react-router';
77
import { mdxComponents } from '@/components/mdx';
@@ -23,6 +23,14 @@ import { App } from './App';
2323

2424
import clientAssets from './entry-client?assets=client';
2525
import serverAssets from './entry-server?assets=ssr';
26+
// Page modules are lazy-loaded (React.lazy), so their CSS is normally injected
27+
// by Vite only when the JS chunk loads. Link the active route's page CSS in the
28+
// SSR head so pages are fully styled before hydration (and without JavaScript).
29+
import docsLayoutAssets from '@/pages/DocsLayout?assets=client';
30+
import docsPageAssets from '@/pages/DocsPage?assets=client';
31+
import landingPageAssets from '@/pages/LandingPage?assets=client';
32+
import apiLayoutAssets from '@/pages/ApiLayout?assets=client';
33+
import apiPageAssets from '@/pages/ApiPage?assets=client';
2634

2735
function errorResponse(status: number, title: string, message: string): Response {
2836
const safe = message.replace(/[<>&"]/g, '');
@@ -138,10 +146,26 @@ export default {
138146
};
139147
const safeJson = JSON.stringify(embeddedData).replace(/</g, '\\u003c');
140148

141-
const assets = clientAssets.merge(serverAssets);
149+
const routeAssets = isApiRoute
150+
? [apiLayoutAssets, apiPageAssets]
151+
: route.type === RouteType.DocsIndex
152+
? [docsLayoutAssets, landingPageAssets]
153+
: [docsLayoutAssets, docsPageAssets];
154+
// Dev needs serverAssets: client CSS is collected per-module there. In
155+
// production the client build is a single stylesheet (cssCodeSplit:
156+
// false) that already contains everything in import order — the SSR
157+
// build's per-chunk CSS would duplicate it and, linking later, override
158+
// theme rules with Apsara's base styles.
159+
const assets = import.meta.dev
160+
? clientAssets.merge(serverAssets, ...routeAssets)
161+
: clientAssets.merge(...routeAssets);
142162

143163
const renderStart = performance.now();
144-
const stream = await renderToReadableStream(
164+
// prerender (vs renderToReadableStream) waits for all Suspense content;
165+
// the huge progressiveChunkSize stops React from "outlining" large completed
166+
// boundaries into hidden divs revealed by inline scripts — together they make
167+
// pages render fully without JavaScript enabled
168+
const { prelude } = await prerender(
145169
<html lang="en">
146170
<head>
147171
<meta charSet="UTF-8" />
@@ -195,8 +219,8 @@ export default {
195219
</div>
196220
</body>
197221
</html>,
222+
{ progressiveChunkSize: Number.MAX_SAFE_INTEGER },
198223
);
199-
await stream.allReady;
200224

201225
const renderDuration = performance.now() - renderStart;
202226

@@ -205,9 +229,14 @@ export default {
205229
// biome-ignore lint/correctness/useHookAtTopLevel: useNitroApp is a Nitro DI accessor, not a React hook
206230
useNitroApp().hooks.callHook('chronicle:ssr-rendered', pathname, status, renderDuration);
207231

208-
return new Response(stream, {
232+
return new Response(prelude, {
209233
status,
210-
headers: { 'Content-Type': 'text/html;charset=utf-8' },
234+
headers: {
235+
'Content-Type': 'text/html;charset=utf-8',
236+
// Hashed asset URLs change on every deploy; force browsers to
237+
// revalidate the HTML so it never references deleted assets
238+
'Cache-Control': 'no-cache',
239+
},
211240
});
212241
} catch (err) {
213242
console.error(`[chronicle] SSR error for ${pathname}:`, err);

‎packages/chronicle/src/server/entry-static.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import '@vitejs/plugin-react/preamble';
2+
// see entry-client.tsx — Apsara CSS must load from the entry, before theme CSS
3+
import '@raystack/apsara/normalize.css';
4+
import '@raystack/apsara/style.css';
25
import React from 'react';
36
import { createRoot } from 'react-dom/client';
47
import { BrowserRouter } from 'react-router';
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { describe, expect, test } from 'bun:test';
2+
import React, { lazy, Suspense } from 'react';
3+
import { prerender } from 'react-dom/static.edge';
4+
5+
// Contract tests for the SSR rendering strategy used in entry-server.tsx.
6+
//
7+
// Pages must be fully readable with JavaScript disabled. React's streaming
8+
// renderer emits Suspense content as `<div hidden id="S:*">` segments revealed
9+
// by inline `$RC` scripts, and — crucially — `prerender` still "outlines"
10+
// completed boundaries larger than progressiveChunkSize (12,800 bytes by
11+
// default) into that same mechanism. entry-server.tsx therefore renders with
12+
// prerender + progressiveChunkSize: Number.MAX_SAFE_INTEGER. These tests pin
13+
// that contract against react-dom upgrades.
14+
15+
const NO_JS_RENDER_OPTIONS = { progressiveChunkSize: Number.MAX_SAFE_INTEGER };
16+
17+
// Well over the 12,800-byte default, like any real docs page.
18+
const BIG_TEXT = 'chronicle-no-js-content '.repeat(1000);
19+
20+
function lazyAfterTick<T extends React.ComponentType>(Component: T) {
21+
return lazy(
22+
() => new Promise<{ default: T }>(resolve => setTimeout(() => resolve({ default: Component }), 10))
23+
);
24+
}
25+
26+
async function renderToHtml(node: React.ReactNode, options?: { progressiveChunkSize?: number }) {
27+
const { prelude } = await prerender(
28+
<html lang="en">
29+
<head />
30+
<body>{node}</body>
31+
</html>,
32+
options
33+
);
34+
return new Response(prelude).text();
35+
}
36+
37+
function LargePage() {
38+
return (
39+
<main>
40+
<h1>Getting Started</h1>
41+
<p>{BIG_TEXT}</p>
42+
</main>
43+
);
44+
}
45+
46+
function expectFullyInlined(html: string) {
47+
expect(html).not.toContain('<!--$?-->'); // pending boundary marker
48+
expect(html).not.toContain('hidden id="S:'); // outlined content segment
49+
expect(html).not.toContain('$RC('); // client-side reveal script
50+
expect(html).not.toContain('<template id="B:'); // fallback placeholder
51+
}
52+
53+
describe('SSR no-JS contract (prerender + unbounded progressiveChunkSize)', () => {
54+
test('inlines large lazy Suspense content in place', async () => {
55+
const Page = lazyAfterTick(LargePage);
56+
const html = await renderToHtml(
57+
<Suspense fallback={<span>skeleton</span>}>
58+
<Page />
59+
</Suspense>,
60+
NO_JS_RENDER_OPTIONS
61+
);
62+
63+
expectFullyInlined(html);
64+
expect(html).toContain(BIG_TEXT.slice(0, 100));
65+
expect(html).not.toContain('skeleton');
66+
});
67+
68+
test('inlines nested Suspense boundaries (layout > page > toc shape)', async () => {
69+
const Toc = lazyAfterTick(() => <nav>toc-entries</nav>);
70+
const Page = lazyAfterTick(() => (
71+
<article>
72+
<p>{BIG_TEXT}</p>
73+
<Suspense fallback={null}>
74+
<Toc />
75+
</Suspense>
76+
</article>
77+
));
78+
const html = await renderToHtml(
79+
<Suspense fallback={<span>skeleton</span>}>
80+
<Page />
81+
</Suspense>,
82+
NO_JS_RENDER_OPTIONS
83+
);
84+
85+
expectFullyInlined(html);
86+
expect(html).toContain('toc-entries');
87+
expect(html).toContain(BIG_TEXT.slice(0, 100));
88+
});
89+
90+
test('waits for slow lazy chunks instead of emitting fallbacks', async () => {
91+
const Slow = lazy(
92+
() =>
93+
new Promise<{ default: React.ComponentType }>(resolve =>
94+
setTimeout(() => resolve({ default: () => <p>slow-chunk-content</p> }), 100)
95+
)
96+
);
97+
const html = await renderToHtml(
98+
<Suspense fallback={<span>skeleton</span>}>
99+
<Slow />
100+
</Suspense>,
101+
NO_JS_RENDER_OPTIONS
102+
);
103+
104+
expectFullyInlined(html);
105+
expect(html).toContain('slow-chunk-content');
106+
expect(html).not.toContain('skeleton');
107+
});
108+
109+
// Documents WHY entry-server.tsx must pass progressiveChunkSize: with the
110+
// react-dom default, a completed boundary this large is still outlined into
111+
// hidden divs that only inline scripts can reveal. If this test ever fails,
112+
// React changed the outlining behavior and the override can be revisited.
113+
test('default progressiveChunkSize would break no-JS rendering', async () => {
114+
const Page = lazyAfterTick(LargePage);
115+
const html = await renderToHtml(
116+
<Suspense fallback={<span>skeleton</span>}>
117+
<Page />
118+
</Suspense>
119+
);
120+
121+
expect(html).toContain('<!--$?-->');
122+
expect(html).toContain('hidden id="S:');
123+
expect(html).toContain('$RC(');
124+
expect(html).toContain('skeleton');
125+
});
126+
});

‎packages/chronicle/src/server/vite-config.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,12 @@ export async function createViteConfig(
260260
client: {
261261
build: {
262262
manifest: isStaticPreset(preset),
263+
// Single stylesheet: per-chunk CSS splitting emitted Apsara's CSS
264+
// into a shared chunk that linked after theme CSS and overrode it
265+
// (equal specificity, later order wins). One file makes the cascade
266+
// follow module import order — Apsara base first, themes after —
267+
// and guarantees lazy page chunks never render unstyled without JS.
268+
cssCodeSplit: false,
263269
rollupOptions: {
264270
input: path.resolve(
265271
packageRoot,

‎packages/chronicle/src/themes/default/Layout.module.css‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,17 @@
77
flex: 1;
88
}
99

10-
.sidebar {
10+
/* .layout aside.sidebar outranks Apsara's .root[data-open] rules; bundler
11+
CSS order is not guaranteed, so theme overrides must win on specificity */
12+
.layout aside.sidebar {
1113
width: 262px;
1214
flex: 0 0 262px;
1315
display: flex;
1416
flex-direction: column;
1517
position: sticky;
1618
top: 0;
1719
height: 100vh;
20+
min-height: 100vh;
1821
background: var(--rs-color-background-base-secondary);
1922
}
2023

@@ -39,7 +42,7 @@
3942
display: block;
4043
}
4144

42-
.sidebar[data-position='left'] {
45+
.layout aside.sidebar[data-position='left'] {
4346
border-right: none;
4447
padding: 0;
4548
}
@@ -66,7 +69,7 @@
6669
gap: var(--rs-space-3);
6770
}
6871

69-
.sidebarMain {
72+
.layout .sidebarMain {
7073
padding: var(--rs-space-7) var(--rs-space-5);
7174
gap: 0;
7275
min-height: 0;
@@ -114,7 +117,7 @@
114117
display: none;
115118
}
116119

117-
.sidebarHeader {
120+
.layout .sidebarHeader {
118121
display: flex;
119122
align-items: center;
120123
justify-content: space-between;
@@ -127,6 +130,7 @@
127130
border-bottom: 0.5px solid var(--rs-color-border-base-primary);
128131
border-radius: 0;
129132
backdrop-filter: blur(1px);
133+
overflow: visible;
130134
}
131135

132136
.mainArea {
@@ -155,6 +159,9 @@
155159
display: flex;
156160
align-items: center;
157161
justify-content: space-between;
162+
position: sticky;
163+
top: 0;
164+
z-index: 10;
158165
height: var(--navbar-height);
159166
padding: var(--rs-space-4) var(--rs-space-8);
160167
background: var(--rs-color-background-base-primary);

‎scripts/smoke-test.sh‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,27 @@ FIRST_LOAD_BUDGET="${FIRST_LOAD_BUDGET:-60}"
5151
WARM_LOAD_BUDGET="${WARM_LOAD_BUDGET:-10}"
5252
BODY_FILE="smoke-${MODE}-page.html"
5353

54-
first_time=$(curl -sfL -o "$BODY_FILE" -w '%{time_total}' --max-time "$FIRST_LOAD_BUDGET" "${BASE}/") \
55-
|| fail "first page load failed or exceeded ${FIRST_LOAD_BUDGET}s budget"
54+
# The health endpoint comes up before Vite's SSR environment in dev mode, so
55+
# early page loads can 503 while the environment warms — retry within budget
56+
first_time=""
57+
status="000"
58+
first_start=$SECONDS
59+
while :; do
60+
remaining=$(( FIRST_LOAD_BUDGET - (SECONDS - first_start) ))
61+
(( remaining > 0 )) || break
62+
result=$(curl -sL -o "$BODY_FILE" -w '%{http_code} %{time_total}' \
63+
--max-time "$remaining" "${BASE}/") || result="000 0"
64+
status="${result%% *}"
65+
if [[ "$status" == "200" ]]; then
66+
first_time="${result##* }"
67+
break
68+
fi
69+
remaining=$(( FIRST_LOAD_BUDGET - (SECONDS - first_start) ))
70+
(( remaining > 0 )) || break
71+
sleep "$(( remaining < 2 ? remaining : 2 ))"
72+
done
73+
[[ -n "$first_time" ]] \
74+
|| fail "first page load failed or exceeded ${FIRST_LOAD_BUDGET}s budget (last status ${status})"
5675
if ! grep -q 'getting-started' "$BODY_FILE"; then
5776
fail "homepage does not contain expected content"
5877
fi

0 commit comments

Comments
 (0)