Skip to content

Commit f74becd

Browse files
authored
Merge pull request #9 from PureStackStudio/targeted-dev-rerenders
Re-render only pages affected by header, footer and navigation edits, serve stale dev pages fresh.
2 parents 5adb85e + 20df31e commit f74becd

14 files changed

Lines changed: 452 additions & 194 deletions

File tree

‎.gitignore‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,5 @@ PureGate
88
purestack-setup
99
*.vsix
1010
.codex/*
11-
/.tmp
11+
/.tmp*
1212
/frontend/out/

‎packages/ts-ssg/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,7 @@ Tracked:
412412
In dev/watch mode:
413413

414414
- file changes apply incrementally when safe,
415+
- a page affected by a shared change, such as a header, footer, or navigation edit, renders again on its next request, before it is served,
415416
- site config changes trigger full rebuild,
416417
- lazy route render can happen on first request for missing HTML route,
417418
- live reload is served over SSE (`/__ts-ssg/events`).

‎packages/ts-ssg/src/build/assets.test.ts‎

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import path from 'node:path'
44

55
import { disableLogger, getLogger, type Logger } from 'logpot'
66
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
7+
import { makeRepoTempDir } from '../test/repoTempDir'
78

89
import {
910
type CopyStaticAssetsResult,
@@ -133,9 +134,7 @@ describe('static assets', () => {
133134
})
134135

135136
it('minifies compiled ts assets when requested', async () => {
136-
const root = await fs.mkdtemp(
137-
path.join(process.cwd(), '.tmp-ts-ssg-assets-'),
138-
)
137+
const root = await makeRepoTempDir('.tmp-ts-ssg-assets-')
139138
const outDir = path.join(root, 'dist')
140139
try {
141140
const entryPath = path.join(root, 'login.ts')
@@ -171,9 +170,7 @@ describe('static assets', () => {
171170
})
172171

173172
it('bundles source-condition packages after stripping unused Regor template tags', async () => {
174-
const root = await fs.mkdtemp(
175-
path.join(process.cwd(), '.tmp-ts-ssg-assets-'),
176-
)
173+
const root = await makeRepoTempDir('.tmp-ts-ssg-assets-')
177174
const outDir = path.join(root, 'dist')
178175
try {
179176
await writeFile(
@@ -257,9 +254,7 @@ describe('static assets', () => {
257254
})
258255

259256
it('removes stale cache-keyed script siblings after writing a new bundle', async () => {
260-
const root = await fs.mkdtemp(
261-
path.join(process.cwd(), '.tmp-ts-ssg-assets-'),
262-
)
257+
const root = await makeRepoTempDir('.tmp-ts-ssg-assets-')
263258
const outDir = path.join(root, 'dist')
264259
try {
265260
const entryPath = path.join(root, 'login.ts')

‎packages/ts-ssg/src/build/incremental/change-applier.ts‎

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export class IncrementalChangeApplier {
4444
changedAssets: 0,
4545
deletedPages: 0,
4646
deletedAssets: 0,
47+
markedPages: 0,
4748
reason,
4849
}
4950
}
@@ -127,14 +128,7 @@ export class IncrementalChangeApplier {
127128
}
128129

129130
if (this.input.config.navigation.mode !== 'none') {
130-
const contentFiles =
131-
await this.input.contentState.refreshNavigationAndMarkDirty()
132-
await this.input.contentState.rebuildContentRelPaths(
133-
contentFiles.map((file) => file.relPath),
134-
state.result,
135-
)
136-
await this.input.persistManifest()
137-
return
131+
await this.input.contentState.refreshNavigation()
138132
}
139133

140134
if (state.assetEntry) {
@@ -158,16 +152,15 @@ export class IncrementalChangeApplier {
158152
signature: FileSignature | null,
159153
) {
160154
if (!signature) {
161-
await this.input.contentState.refreshNavigationAndMarkDirty()
155+
await this.input.contentState.refreshNavigation()
162156
await this.input.contentState.removeContentEntryForDeletedSource(
163157
relPath,
164158
result,
165159
)
166160
return
167161
}
168162

169-
const contentFiles =
170-
await this.input.contentState.refreshNavigationAndMarkDirty()
163+
const contentFiles = await this.input.contentState.refreshNavigation()
171164
await this.input.contentState.rebuildNavigatedContent({
172165
contentFiles,
173166
relPath,

‎packages/ts-ssg/src/build/incremental/content-state.ts‎

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import {
1818
type BuildContext,
1919
buildPage,
2020
renderPageFromFile,
21+
resolveHeaderFooterHtml,
22+
resolvePagePartials,
2123
writePage,
2224
writePageError,
2325
} from '../page'
@@ -57,6 +59,7 @@ interface RebuildSingleContentInput {
5759

5860
export class IncrementalContentState {
5961
private readonly dirtyPages = new Set<string>()
62+
private markedPages = 0
6063
private readonly renderInFlight = new Map<string, Promise<boolean>>()
6164
private readonly contentIndex: ManifestContentIndex
6265

@@ -76,6 +79,13 @@ export class IncrementalContentState {
7679
this.dirtyPages.clear()
7780
}
7881

82+
/** Pages marked to render on their next request since the last call. */
83+
takeMarkedPageCount() {
84+
const count = this.markedPages
85+
this.markedPages = 0
86+
return count
87+
}
88+
7989
async renderAllPages(contentFiles: ResolvedContentFile[], hooks: BuildHooks) {
8090
let pages = 0
8191
for (const file of contentFiles) {
@@ -148,17 +158,48 @@ export class IncrementalContentState {
148158
if (filesChanged) this.markAllPagesDirty(contentRoutes.pages)
149159
}
150160

151-
async refreshNavigationAndMarkDirty() {
161+
/**
162+
* Rebuilds navigation. Every page shows it, so all pages become dirty when
163+
* it changes; an edit that leaves it alone touches no other page.
164+
*/
165+
async refreshNavigation() {
166+
const { config, context } = this.input
152167
const contentFiles = await this.refreshContent()
153-
this.input.context.navigation = await buildNavigation(
154-
this.input.config.contentDir,
168+
const navigation = await buildNavigation(
169+
config.contentDir,
155170
contentFiles,
156-
this.input.config.navigation,
171+
config.navigation,
157172
)
158-
this.markAllPagesDirty(contentFiles)
173+
if (JSON.stringify(navigation) !== JSON.stringify(context.navigation)) {
174+
context.navigation = navigation
175+
this.markAllPagesDirty(contentFiles)
176+
}
159177
return contentFiles
160178
}
161179

180+
/**
181+
* Compiles the header and footer partials again. Only pages whose nearest
182+
* header or footer changed become dirty, so an edit in one folder leaves
183+
* pages elsewhere alone.
184+
*/
185+
async refreshPartials() {
186+
const { context } = this.input
187+
const pages = context.contentRoutes.pages
188+
const before = pages.map((page) => resolvePagePartials(context, page))
189+
await resolveHeaderFooterHtml(context)
190+
this.markPagesDirty(
191+
pages
192+
.filter((page, index) => {
193+
const after = resolvePagePartials(context, page)
194+
return (
195+
after.headerHtml !== before[index].headerHtml ||
196+
after.footerHtml !== before[index].footerHtml
197+
)
198+
})
199+
.map((page) => page.relPath),
200+
)
201+
}
202+
162203
async removeContentEntryForDeletedSource(
163204
relPath: string,
164205
result: IncrementalBuildResult,
@@ -259,11 +300,14 @@ export class IncrementalContentState {
259300
)
260301
}
261302

262-
private markAllPagesDirty(contentFiles: ResolvedContentFile[]) {
303+
private markAllPagesDirty(contentFiles: readonly ResolvedContentFile[]) {
263304
this.dirtyPages.clear()
264-
for (const file of contentFiles) {
265-
this.dirtyPages.add(file.relPath)
266-
}
305+
this.markPagesDirty(contentFiles.map((file) => file.relPath))
306+
}
307+
308+
private markPagesDirty(relPaths: readonly string[]) {
309+
for (const relPath of relPaths) this.dirtyPages.add(relPath)
310+
this.markedPages += relPaths.length
267311
}
268312

269313
private async renderPageByRelPath(relPath: string, onlyIfDirty: boolean) {

‎packages/ts-ssg/src/build/incremental/incremental.md‎

Lines changed: 24 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -52,44 +52,32 @@ The output is deterministic with `generatedAt` being the only volatile field.
5252

5353
## `applyChange(filePath)`
5454

55-
Handles a single filesystem change event. It is structured as a fast path with
56-
early exits:
55+
Handles a single filesystem change event. The changed page renders right
56+
away; other affected pages are marked dirty and render on their next request
57+
(`renderIfDirtyByOutPath`). `IncrementalBuildResult.markedPages` counts them,
58+
so the dev server can reload the browser.
5759

5860
1. **Ignore changes outside `contentDir`**: relative path starting with `..`.
59-
2. **Config file change**: if `isSiteConfigFile` ➜ mark `fullRebuild = true`.
60-
3. **Missing file (deletion)**:
61-
- If a content entry exists and navigation is enabled ➜ rebuild navigation
62-
for the affected subtree, update manifest, return.
63-
- Otherwise delete the output(s), remove manifest entries, update counters.
64-
4. **Content change**:
65-
- If the file is content (or was previously treated as content):
66-
- No signature change ➜ no-op.
67-
- With navigation enabled ➜ rebuild navigation subtree.
68-
- Otherwise rebuild the single page and update its manifest entry.
69-
5. **Asset change**:
70-
- If signature unchanged ➜ no-op.
71-
- Otherwise copy static asset, update manifest entry.
72-
73-
The intent is to keep work proportional to the change while preserving
74-
navigation correctness when navigation is derived from content.
75-
76-
## Navigation-sensitive rebuilds
77-
78-
When navigation is enabled, a single content change can affect:
79-
80-
- The changed page.
81-
- Its ancestor folders (navigation nodes).
82-
- Any siblings within the affected folder scope.
83-
84-
`rebuildNavigationForChange` therefore:
85-
86-
1. Re-discovers content and rebuilds the full navigation tree.
87-
2. Computes affected folders up to `navigationConfig.maxDepth`.
88-
3. Rebuilds all pages in those folders.
89-
4. Deletes pages in those folders that no longer exist.
90-
91-
This favors correctness over minimal work whenever navigation structure is
92-
content-derived.
61+
2. **`siteConfig.json`** ➜ `fullRebuild = true`. Config shapes every output.
62+
3. **Header or footer partial** (edit, add, or delete) ➜ compile the partials
63+
again and mark only the pages whose nearest header or footer HTML changed.
64+
4. **`_nav.json`** ➜ rebuild navigation; mark every page only if it changed.
65+
5. **Content change**: no signature change ➜ no-op. Otherwise render the page,
66+
refresh the content index and navigation, and mark pages as below.
67+
6. **Content deletion**: remove the output and manifest entry, then refresh.
68+
7. **Asset change**: copy the asset and update its manifest entry. A `.ts`
69+
change rebuilds the script bundles that import it.
70+
71+
## What marks other pages
72+
73+
- **Navigation changed** ➜ every page, since every page shows navigation.
74+
Navigation is compared as data, so an edit that leaves titles, order, and
75+
structure alone marks nothing.
76+
- **A page or asset was added or removed** ➜ every page, since any content URL
77+
may now resolve differently or fail.
78+
- **A header or footer changed** ➜ the pages that show it.
79+
- **A script bundle's hashed name changed** ➜ the pages that load it, rendered
80+
right away.
9381

9482
## Manifest assembly
9583

0 commit comments

Comments
 (0)