Skip to content

Commit 185d03a

Browse files
chrisbbreuerclaude
andcommitted
fix(ssg): give the resolved config the router key its pages read
`main` has not typechecked since 566dfde, and four releases went out on it — 0.2.259 through 0.2.263. That commit fixed the SSG's router forwarding by calling `injectRouterScript(html, { router: options.router })` inside `renderPage`, and took an `any` cast off `SSGConfig.router` so the field had a real type. Both halves were right; the value was not there. `renderPage` is called with the `cfg` built in `generateStaticSite`, so its `options.router` is `cfg.router` — and `cfg` never had a `router` key. Every page read `undefined`, no `__stxRouterConfig` was emitted, and a project that set `container` or `prefetch` still got the client's built-in defaults. The fix was inert. Nothing caught it except the type. `Required<SSGConfig>` turned the missing key into TS2741, which is the compiler reporting precisely the bug that commit set out to fix — and it was left unresolved, so the signal became noise on every run since. So populate the key. It reads the loaded config rather than `buildConfig` because `router` is a top-level key of `stx.config.ts`, like `partialsDir` beside it, and an explicit call option wins, like every other key in the object. The tests read the built HTML. An inert fix passes anything that asserts on the call site, which is how this shipped in the first place, so these run `generateStaticSite` against a project that declares a container and check what comes out — including flags set to `false`, the case a truthy forward drops while looking correct. Verified to fail against the unfixed file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 9d70685 commit 185d03a

2 files changed

Lines changed: 101 additions & 0 deletions

File tree

packages/stx/src/ssg.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1089,6 +1089,18 @@ export async function generateStaticSite(options: SSGConfig = {}): Promise<SSGRe
10891089
console.warn(siteUrlFallbackWarning())
10901090

10911091
const cfg: Required<SSGConfig> = {
1092+
// The router config the injected script is handed, and the reason
1093+
// `SSGConfig.router` was given a real type: `renderPage` is called with
1094+
// this object, so its `options.router` is this key. It was never set, so
1095+
// the forwarding added in 566dfdead read `undefined` on every page and
1096+
// emitted no `__stxRouterConfig` — the fix was inert, and the compiler has
1097+
// been saying so since the `any` cast came off.
1098+
//
1099+
// Read from the loaded config rather than `buildConfig`, because `router`
1100+
// is a top-level key of `stx.config.ts` like `partialsDir` below. The `??`
1101+
// is for the type only: `loadStxConfig` merges `defaultConfig`, which
1102+
// always carries a `router`, so the fallback is unreachable at runtime.
1103+
router: options.router ?? (stxConfig as any)?.router ?? {},
10921104
pagesDir: options.pagesDir || buildConfig.pagesDir || 'pages',
10931105
outputDir: options.outputDir || buildConfig.outputDir || 'dist',
10941106
baseUrl: options.baseUrl || buildConfig.baseUrl || '/',
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/**
2+
* A project's router settings reach the pages the static build generates.
3+
*
4+
* `renderPage` hands `injectRouterScript` its own `options.router`, and the
5+
* object it is given is the resolved `cfg` built in `generateStaticSite`.
6+
* That object had no `router` key, so the value was `undefined` on every page
7+
* and no `__stxRouterConfig` was emitted at all — a project that set
8+
* `container` or `prefetch` in `stx.config.ts` got the client's built-in
9+
* defaults instead, and the same source navigated differently depending on
10+
* whether the SSG or the dev server had rendered it (stacksjs/stx#1792 P2).
11+
*
12+
* The forwarding at the call site was added first and did nothing, because the
13+
* key it read was never populated. Nothing failed; the pages were merely wrong.
14+
* These tests are the thing that was missing — they read the built HTML, so an
15+
* inert fix cannot pass them.
16+
*/
17+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
18+
import { existsSync, readFileSync } from 'node:fs'
19+
import { mkdtemp, rm } from 'node:fs/promises'
20+
import { tmpdir } from 'node:os'
21+
import path from 'node:path'
22+
23+
let dir = ''
24+
const originalCwd = process.cwd()
25+
26+
beforeEach(async () => {
27+
dir = await mkdtemp(path.join(tmpdir(), 'stx-router-cfg-'))
28+
await Bun.write(path.join(dir, 'views', 'index.stx'), '<main><h1>Home</h1></main>\n')
29+
})
30+
31+
afterEach(async () => {
32+
process.chdir(originalCwd)
33+
if (dir)
34+
await rm(dir, { recursive: true, force: true })
35+
})
36+
37+
/** Write the project's `stx.config.ts`, the way a real project declares this. */
38+
async function writeConfig(router: Record<string, unknown>): Promise<void> {
39+
await Bun.write(
40+
path.join(dir, 'stx.config.ts'),
41+
`export default ${JSON.stringify({ router }, null, 2)}\n`,
42+
)
43+
}
44+
45+
async function build(options: Record<string, unknown> = {}): Promise<string> {
46+
process.chdir(dir)
47+
const { generateStaticSite } = await import('../../src/ssg')
48+
await generateStaticSite({ pagesDir: 'views', outputDir: 'dist', ...options })
49+
const out = path.join(dir, 'dist', 'index.html')
50+
return existsSync(out) ? readFileSync(out, 'utf8') : ''
51+
}
52+
53+
/** The one line the page carries the config on. */
54+
function routerConfig(html: string): Record<string, unknown> | null {
55+
const match = html.match(/window\.__stxRouterConfig\s*=\s*(\{.*?\});/s)
56+
return match ? JSON.parse(match[1]) : null
57+
}
58+
59+
describe('the static build forwards router config', () => {
60+
it('carries a container declared in stx.config.ts onto the page', async () => {
61+
await writeConfig({ container: '#app-shell' })
62+
63+
const config = routerConfig(await build())
64+
65+
expect(config).not.toBeNull()
66+
expect(config?.container).toBe('#app-shell')
67+
})
68+
69+
it('carries the flags too, including the ones that are false', async () => {
70+
// `false` is the case a truthy forward drops, and turning a feature off is
71+
// the reason anyone writes it down.
72+
await writeConfig({ container: 'main', prefetch: false, viewTransitions: false })
73+
74+
const config = routerConfig(await build())
75+
76+
expect(config?.prefetch).toBe(false)
77+
expect(config?.viewTransitions).toBe(false)
78+
})
79+
80+
it('lets an explicit call option win over the config', async () => {
81+
// Matches every other key in the resolved config: what the caller passes
82+
// beats what the project declared.
83+
await writeConfig({ container: '#from-config' })
84+
85+
const config = routerConfig(await build({ router: { container: '#from-caller' } }))
86+
87+
expect(config?.container).toBe('#from-caller')
88+
})
89+
})

0 commit comments

Comments
 (0)