Skip to content

Commit f413ef3

Browse files
committed
Avoid redundant dev-server generation and batch watcher updates.
1 parent b5ea82e commit f413ef3

9 files changed

Lines changed: 327 additions & 21 deletions

File tree

‎packages/ts-css/src/baseStyle.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ import { hyphenizeCss } from './hyphenize'
66

77
export abstract class RootStyle {
88
static nextId = 1
9+
static revision = 0
910
id: number
1011
selector: string
1112
constructor(selector?: string) {
1213
this.id = BaseStyle.nextId++
1314
this.selector = selector ?? ''
15+
RootStyle.revision += 1
1416
}
1517
abstract toCSS(): string
1618
}
@@ -25,7 +27,11 @@ export class BaseStyle<T extends RootStyle> extends RootStyle {
2527
}
2628

2729
set(key: string, value: string | number) {
28-
this.props.set(hyphenizeCss(key), autoVar(value))
30+
key = hyphenizeCss(key)
31+
value = autoVar(value)
32+
if (this.props.get(key) === value) return this
33+
this.props.set(key, value)
34+
RootStyle.revision += 1
2935
return this
3036
}
3137

@@ -84,7 +90,9 @@ export class BaseStyle<T extends RootStyle> extends RootStyle {
8490

8591
use(css: T) {
8692
this.#asBaseStyle(css).props.forEach((value, key) => {
93+
if (this.props.get(key) === value) return
8794
this.props.set(key, value)
95+
RootStyle.revision += 1
8896
})
8997
return this
9098
}

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,21 @@ export class IncrementalContentState {
7474
private unindexedPages = new Map<string, string>()
7575
private readonly renderInFlight = new Map<string, Promise<boolean>>()
7676
private readonly contentIndex: ManifestContentIndex
77+
private changeBatch:
78+
| {
79+
content?: Promise<ResolvedContentFile[]>
80+
navigation?: Promise<ResolvedContentFile[]>
81+
}
82+
| undefined
83+
84+
async withChangeBatch<T>(apply: () => Promise<T>): Promise<T> {
85+
this.changeBatch = {}
86+
try {
87+
return await apply()
88+
} finally {
89+
this.changeBatch = undefined
90+
}
91+
}
7792

7893
constructor(private readonly input: IncrementalContentStateInput) {
7994
this.contentIndex = new ManifestContentIndex(
@@ -185,6 +200,14 @@ export class IncrementalContentState {
185200
* the same pages cost nothing beyond discovery.
186201
*/
187202
async refreshContent() {
203+
if (this.changeBatch) {
204+
this.changeBatch.content ??= this.discoverContent()
205+
return this.changeBatch.content
206+
}
207+
return this.discoverContent()
208+
}
209+
210+
private async discoverContent() {
188211
const { config, context, plugins } = this.input
189212
const contentFiles = await discoverSiteContent(config, plugins)
190213
const generated = this.trackGeneratedPages(contentFiles)
@@ -263,6 +286,14 @@ export class IncrementalContentState {
263286
* it changes; an edit that leaves it alone touches no other page.
264287
*/
265288
async refreshNavigation() {
289+
if (this.changeBatch) {
290+
this.changeBatch.navigation ??= this.rebuildNavigation()
291+
return this.changeBatch.navigation
292+
}
293+
return this.rebuildNavigation()
294+
}
295+
296+
private async rebuildNavigation() {
266297
const { config, context } = this.input
267298
const contentFiles = await this.refreshContent()
268299
const navigation = await buildNavigation(

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

Lines changed: 157 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import fs from 'node:fs/promises'
22
import path from 'node:path'
3+
import { styleBuilder } from '@purestack/ts-style'
34

45
import { disableLogger, getLogger, type Logger } from 'logpot'
5-
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
6+
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
67
import { resolveSiteConfig } from '../../config/config'
78
import { parseFrontmatterSource } from '../../frontmatter/frontmatter'
89
import type { PureStackPlugin } from '../../plugins/plugin'
@@ -59,6 +60,161 @@ describe('incremental builder', () => {
5960
await logger?.close()
6061
})
6162

63+
it('keeps style changes during CSS generation pending for the next request', async () => {
64+
await withTempDir(async (base) => {
65+
const contentDir = path.join(base, 'content')
66+
const outDir = path.join(base, 'out')
67+
await fs.mkdir(contentDir)
68+
await fs.writeFile(path.join(contentDir, 'index.md'), '# Home')
69+
let writes = 0
70+
const builder = await createIncrementalBuilder({
71+
siteConfig: { rootDir: base, contentDir, outDir },
72+
options: {
73+
plugins: [
74+
{
75+
name: 'count-css',
76+
hooks: {
77+
onStylesWritten() {
78+
writes += 1
79+
},
80+
},
81+
},
82+
],
83+
},
84+
})
85+
await builder.prepareForRequests()
86+
await builder.renderByUrlPath('/')
87+
const render = styleBuilder.render.bind(styleBuilder)
88+
let changed = false
89+
const spy = vi
90+
.spyOn(styleBuilder, 'render')
91+
.mockImplementation(async (theme, pretty) => {
92+
const css = await render(theme, pretty)
93+
if (!changed) {
94+
changed = true
95+
styleBuilder.select('.concurrent-style', 'light').color('red')
96+
}
97+
return css
98+
})
99+
try {
100+
await builder.preparePageAssets()
101+
expect(writes).toBe(1)
102+
await builder.preparePageAssets()
103+
expect(writes).toBe(2)
104+
expect(
105+
await fs.readFile(path.join(outDir, 'assets', 'site.css'), 'utf8'),
106+
).toContain('.concurrent-style')
107+
await builder.preparePageAssets()
108+
expect(writes).toBe(2)
109+
} finally {
110+
spy.mockRestore()
111+
}
112+
})
113+
})
114+
115+
it.each(['none', 'hybrid'] as const)(
116+
'shares discovery across watcher bursts with %s navigation',
117+
async (mode) => {
118+
await withTempDir(async (base) => {
119+
const contentDir = path.join(base, 'content')
120+
const outDir = path.join(base, 'out')
121+
await fs.mkdir(contentDir)
122+
const files = Array.from({ length: 10 }, (_, index) =>
123+
path.join(contentDir, `page${index}.md`),
124+
)
125+
for (const file of files) await fs.writeFile(file, '# Original')
126+
let discoveries = 0
127+
const builder = await createIncrementalBuilder({
128+
siteConfig: {
129+
rootDir: base,
130+
contentDir,
131+
outDir,
132+
navigation: { mode },
133+
},
134+
options: {
135+
plugins: [
136+
{
137+
name: 'count-discoveries',
138+
pages() {
139+
discoveries += 1
140+
return []
141+
},
142+
},
143+
],
144+
},
145+
})
146+
await builder.prepareForRequests()
147+
await builder.renderByUrlPath('/page0/')
148+
discoveries = 0
149+
expect(await builder.applyChanges([...files, files[0]])).toHaveLength(
150+
10,
151+
)
152+
expect(discoveries).toBe(1)
153+
154+
await fs.writeFile(files[0], '# Updated')
155+
await fs.rm(files[1])
156+
const added = path.join(contentDir, 'added.md')
157+
await fs.writeFile(added, '# Added')
158+
await builder.applyChanges([files[0], files[1], added])
159+
expect(discoveries).toBe(2)
160+
expect(await builder.renderByUrlPath('/page0/')).toBe(true)
161+
expect(
162+
await fs.readFile(path.join(outDir, 'page0', 'index.html'), 'utf8'),
163+
).toContain('Updated')
164+
expect(await builder.renderByUrlPath('/added/')).toBe(true)
165+
expect(await builder.renderByUrlPath('/page1/')).toBe(false)
166+
})
167+
},
168+
)
169+
170+
it('stops a change batch on abort and clears its discovery cache after failure', async () => {
171+
await withTempDir(async (base) => {
172+
const contentDir = path.join(base, 'content')
173+
await fs.mkdir(contentDir)
174+
const first = path.join(contentDir, 'first.md')
175+
const second = path.join(contentDir, 'second.md')
176+
await fs.writeFile(first, '# First')
177+
await fs.writeFile(second, '# Second')
178+
const controller = new AbortController()
179+
let abort = false
180+
let fail = false
181+
const builder = await createIncrementalBuilder({
182+
siteConfig: {
183+
rootDir: base,
184+
contentDir,
185+
outDir: path.join(base, 'out'),
186+
},
187+
options: {
188+
plugins: [
189+
{
190+
name: 'control-discovery',
191+
pages() {
192+
if (fail) throw new Error('discovery failed')
193+
if (abort) controller.abort()
194+
return []
195+
},
196+
},
197+
],
198+
},
199+
})
200+
await builder.prepareForRequests()
201+
abort = true
202+
expect(
203+
await builder.applyChanges([first, second], controller.signal),
204+
).toHaveLength(1)
205+
expect(await builder.applyChanges([second], controller.signal)).toEqual(
206+
[],
207+
)
208+
abort = false
209+
fail = true
210+
await expect(builder.applyChanges([first])).rejects.toThrow(
211+
'discovery failed',
212+
)
213+
fail = false
214+
expect(await builder.applyChanges([first, second])).toHaveLength(2)
215+
})
216+
})
217+
62218
it('ignores directory notifications and refreshes removed subtrees without deleting directories as files', async () => {
63219
await withTempDir(async (base) => {
64220
const contentDir = path.join(base, 'content')

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

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import fsPromises from 'node:fs/promises'
22
import path from 'node:path'
33
import type { SiteConfig } from '@purestack/ts-common'
4-
import { themes } from '@purestack/ts-style'
4+
import { styleBuilder, themes } from '@purestack/ts-style'
55
import { toOutputAssetRelPath, toPosixPath } from '@purestack/ts-util'
66
import { getLogger, type Logger } from 'logpot'
77
import {
@@ -174,6 +174,7 @@ class IncrementalRuntime {
174174
private assetWork: Promise<unknown> = Promise.resolve()
175175
private pageAssetRevision = 0
176176
private preparedAssetRevision = -1
177+
private preparedStyleRevision = -1
177178
private renderOnRequest = false
178179
private staticAssets = new Map<string, StaticAssetFile>()
179180
private searchRevision = -1
@@ -227,6 +228,7 @@ class IncrementalRuntime {
227228
prepareForRequests: this.prepareForRequests,
228229
buildAll: this.buildAll,
229230
applyChange: this.applyChange,
231+
applyChanges: this.applyChanges,
230232
renderIfDirtyByOutPath: this.renderIfDirtyByOutPath,
231233
renderByUrlPath: this.renderByUrlPath,
232234
preparePageAssets: this.preparePageAssets,
@@ -422,6 +424,7 @@ class IncrementalRuntime {
422424
},
423425
style,
424426
)
427+
this.preparedStyleRevision = styleResult.revision
425428
await hooks.onStylesWritten?.(this.context, styleResult)
426429
return styleResult
427430
}
@@ -549,6 +552,27 @@ class IncrementalRuntime {
549552
return result
550553
}
551554

555+
applyChanges = async (
556+
filePaths: readonly string[],
557+
signal?: AbortSignal,
558+
): Promise<IncrementalBuildResult[]> => {
559+
if (signal?.aborted || filePaths.length === 0) return []
560+
await this.ensureContentReady()
561+
const apply = async () => {
562+
const results: IncrementalBuildResult[] = []
563+
for (const filePath of new Set(filePaths)) {
564+
if (signal?.aborted) break
565+
const result = await this.applyChange(filePath)
566+
results.push(result)
567+
if (result.fullRebuild) break
568+
}
569+
return results
570+
}
571+
return this.renderOnRequest
572+
? this.contentState.withChangeBatch(apply)
573+
: apply()
574+
}
575+
552576
renderIfDirtyByOutPath = async (outPath: string): Promise<boolean> => {
553577
await this.ensureContentReady()
554578
return this.contentState.renderIfDirtyByOutPath(outPath)
@@ -566,15 +590,21 @@ class IncrementalRuntime {
566590
await this.ensureContentReady()
567591
await this.queueAssetWork(async () => {
568592
const revision = this.pageAssetRevision
569-
if (revision === this.preparedAssetRevision) return
593+
if (
594+
revision === this.preparedAssetRevision &&
595+
this.preparedStyleRevision === styleBuilder.revision
596+
)
597+
return
570598
// Only pages rendered in this session have registered script entries.
571599
await this.scriptEntrypoints.buildMissingEntrypoints(
572600
this.changeApplier.createResult('requested page assets'),
573601
)
574-
const styles = await this.writeStylesWithHooks(this.options.hooks)
575-
this.manifest.styles = {
576-
signature: styles.signature,
577-
outputs: styles.outputs,
602+
if (this.preparedStyleRevision !== styleBuilder.revision) {
603+
const styles = await this.writeStylesWithHooks(this.options.hooks)
604+
this.manifest.styles = {
605+
signature: styles.signature,
606+
outputs: styles.outputs,
607+
}
578608
}
579609
this.preparedAssetRevision = revision
580610
})

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ export interface IncrementalBuilder {
1616
prepareForRequests: () => Promise<void>
1717
buildAll: (reason: string) => Promise<BuildResult>
1818
applyChange: (filePath: string) => Promise<IncrementalBuildResult>
19+
/** Shares discovery/navigation across a burst; stops before the next file on abort. */
20+
applyChanges: (
21+
filePaths: readonly string[],
22+
signal?: AbortSignal,
23+
) => Promise<IncrementalBuildResult[]>
1924
renderIfDirtyByOutPath: (outPath: string) => Promise<boolean>
2025
renderByUrlPath: (urlPath: string, locale?: string) => Promise<boolean>
2126
/** Writes styles and discovered scripts before a dev response is served. */

0 commit comments

Comments
 (0)