From 20df31e308614a9c19a29b4a94c516b3b3cec8b4 Mon Sep 17 00:00:00 2001 From: Ahmed Yasin Koculu Date: Sun, 4 Oct 2026 04:08:57 +0200 Subject: [PATCH] Re-render only pages affected by header, footer and navigation edits, serve stale dev pages fresh. --- .gitignore | 2 +- packages/ts-ssg/README.md | 1 + packages/ts-ssg/src/build/assets.test.ts | 13 +- .../src/build/incremental/change-applier.ts | 15 +- .../src/build/incremental/content-state.ts | 62 +++++- .../src/build/incremental/incremental.md | 60 ++--- .../src/build/incremental/incremental.test.ts | 205 +++++++++++++++--- .../ts-ssg/src/build/incremental/index.ts | 16 +- .../ts-ssg/src/build/incremental/types.ts | 2 + packages/ts-ssg/src/build/page.ts | 29 +-- packages/ts-ssg/src/dev/live-reload.ts | 10 - packages/ts-ssg/src/dev/server.test.ts | 138 ++++++++++++ packages/ts-ssg/src/dev/server.ts | 81 +------ packages/ts-ssg/src/test/repoTempDir.ts | 12 + 14 files changed, 452 insertions(+), 194 deletions(-) create mode 100644 packages/ts-ssg/src/dev/server.test.ts create mode 100644 packages/ts-ssg/src/test/repoTempDir.ts diff --git a/.gitignore b/.gitignore index bc2bcdc7..0d315a04 100644 --- a/.gitignore +++ b/.gitignore @@ -8,5 +8,5 @@ PureGate purestack-setup *.vsix .codex/* -/.tmp +/.tmp* /frontend/out/ diff --git a/packages/ts-ssg/README.md b/packages/ts-ssg/README.md index b5cb0e35..d91bb0f6 100644 --- a/packages/ts-ssg/README.md +++ b/packages/ts-ssg/README.md @@ -412,6 +412,7 @@ Tracked: In dev/watch mode: - file changes apply incrementally when safe, +- 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, - site config changes trigger full rebuild, - lazy route render can happen on first request for missing HTML route, - live reload is served over SSE (`/__ts-ssg/events`). diff --git a/packages/ts-ssg/src/build/assets.test.ts b/packages/ts-ssg/src/build/assets.test.ts index 8ae5be03..944d7688 100644 --- a/packages/ts-ssg/src/build/assets.test.ts +++ b/packages/ts-ssg/src/build/assets.test.ts @@ -4,6 +4,7 @@ import path from 'node:path' import { disableLogger, getLogger, type Logger } from 'logpot' import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { makeRepoTempDir } from '../test/repoTempDir' import { type CopyStaticAssetsResult, @@ -133,9 +134,7 @@ describe('static assets', () => { }) it('minifies compiled ts assets when requested', async () => { - const root = await fs.mkdtemp( - path.join(process.cwd(), '.tmp-ts-ssg-assets-'), - ) + const root = await makeRepoTempDir('.tmp-ts-ssg-assets-') const outDir = path.join(root, 'dist') try { const entryPath = path.join(root, 'login.ts') @@ -171,9 +170,7 @@ describe('static assets', () => { }) it('bundles source-condition packages after stripping unused Regor template tags', async () => { - const root = await fs.mkdtemp( - path.join(process.cwd(), '.tmp-ts-ssg-assets-'), - ) + const root = await makeRepoTempDir('.tmp-ts-ssg-assets-') const outDir = path.join(root, 'dist') try { await writeFile( @@ -257,9 +254,7 @@ describe('static assets', () => { }) it('removes stale cache-keyed script siblings after writing a new bundle', async () => { - const root = await fs.mkdtemp( - path.join(process.cwd(), '.tmp-ts-ssg-assets-'), - ) + const root = await makeRepoTempDir('.tmp-ts-ssg-assets-') const outDir = path.join(root, 'dist') try { const entryPath = path.join(root, 'login.ts') diff --git a/packages/ts-ssg/src/build/incremental/change-applier.ts b/packages/ts-ssg/src/build/incremental/change-applier.ts index c92c4d5d..c5bae8df 100644 --- a/packages/ts-ssg/src/build/incremental/change-applier.ts +++ b/packages/ts-ssg/src/build/incremental/change-applier.ts @@ -44,6 +44,7 @@ export class IncrementalChangeApplier { changedAssets: 0, deletedPages: 0, deletedAssets: 0, + markedPages: 0, reason, } } @@ -127,14 +128,7 @@ export class IncrementalChangeApplier { } if (this.input.config.navigation.mode !== 'none') { - const contentFiles = - await this.input.contentState.refreshNavigationAndMarkDirty() - await this.input.contentState.rebuildContentRelPaths( - contentFiles.map((file) => file.relPath), - state.result, - ) - await this.input.persistManifest() - return + await this.input.contentState.refreshNavigation() } if (state.assetEntry) { @@ -158,7 +152,7 @@ export class IncrementalChangeApplier { signature: FileSignature | null, ) { if (!signature) { - await this.input.contentState.refreshNavigationAndMarkDirty() + await this.input.contentState.refreshNavigation() await this.input.contentState.removeContentEntryForDeletedSource( relPath, result, @@ -166,8 +160,7 @@ export class IncrementalChangeApplier { return } - const contentFiles = - await this.input.contentState.refreshNavigationAndMarkDirty() + const contentFiles = await this.input.contentState.refreshNavigation() await this.input.contentState.rebuildNavigatedContent({ contentFiles, relPath, diff --git a/packages/ts-ssg/src/build/incremental/content-state.ts b/packages/ts-ssg/src/build/incremental/content-state.ts index 3aa3bb18..5a9d483f 100644 --- a/packages/ts-ssg/src/build/incremental/content-state.ts +++ b/packages/ts-ssg/src/build/incremental/content-state.ts @@ -18,6 +18,8 @@ import { type BuildContext, buildPage, renderPageFromFile, + resolveHeaderFooterHtml, + resolvePagePartials, writePage, writePageError, } from '../page' @@ -57,6 +59,7 @@ interface RebuildSingleContentInput { export class IncrementalContentState { private readonly dirtyPages = new Set() + private markedPages = 0 private readonly renderInFlight = new Map>() private readonly contentIndex: ManifestContentIndex @@ -76,6 +79,13 @@ export class IncrementalContentState { this.dirtyPages.clear() } + /** Pages marked to render on their next request since the last call. */ + takeMarkedPageCount() { + const count = this.markedPages + this.markedPages = 0 + return count + } + async renderAllPages(contentFiles: ResolvedContentFile[], hooks: BuildHooks) { let pages = 0 for (const file of contentFiles) { @@ -148,17 +158,48 @@ export class IncrementalContentState { if (filesChanged) this.markAllPagesDirty(contentRoutes.pages) } - async refreshNavigationAndMarkDirty() { + /** + * Rebuilds navigation. Every page shows it, so all pages become dirty when + * it changes; an edit that leaves it alone touches no other page. + */ + async refreshNavigation() { + const { config, context } = this.input const contentFiles = await this.refreshContent() - this.input.context.navigation = await buildNavigation( - this.input.config.contentDir, + const navigation = await buildNavigation( + config.contentDir, contentFiles, - this.input.config.navigation, + config.navigation, ) - this.markAllPagesDirty(contentFiles) + if (JSON.stringify(navigation) !== JSON.stringify(context.navigation)) { + context.navigation = navigation + this.markAllPagesDirty(contentFiles) + } return contentFiles } + /** + * Compiles the header and footer partials again. Only pages whose nearest + * header or footer changed become dirty, so an edit in one folder leaves + * pages elsewhere alone. + */ + async refreshPartials() { + const { context } = this.input + const pages = context.contentRoutes.pages + const before = pages.map((page) => resolvePagePartials(context, page)) + await resolveHeaderFooterHtml(context) + this.markPagesDirty( + pages + .filter((page, index) => { + const after = resolvePagePartials(context, page) + return ( + after.headerHtml !== before[index].headerHtml || + after.footerHtml !== before[index].footerHtml + ) + }) + .map((page) => page.relPath), + ) + } + async removeContentEntryForDeletedSource( relPath: string, result: IncrementalBuildResult, @@ -259,11 +300,14 @@ export class IncrementalContentState { ) } - private markAllPagesDirty(contentFiles: ResolvedContentFile[]) { + private markAllPagesDirty(contentFiles: readonly ResolvedContentFile[]) { this.dirtyPages.clear() - for (const file of contentFiles) { - this.dirtyPages.add(file.relPath) - } + this.markPagesDirty(contentFiles.map((file) => file.relPath)) + } + + private markPagesDirty(relPaths: readonly string[]) { + for (const relPath of relPaths) this.dirtyPages.add(relPath) + this.markedPages += relPaths.length } private async renderPageByRelPath(relPath: string, onlyIfDirty: boolean) { diff --git a/packages/ts-ssg/src/build/incremental/incremental.md b/packages/ts-ssg/src/build/incremental/incremental.md index bef2b6d4..353c8566 100644 --- a/packages/ts-ssg/src/build/incremental/incremental.md +++ b/packages/ts-ssg/src/build/incremental/incremental.md @@ -52,44 +52,32 @@ The output is deterministic with `generatedAt` being the only volatile field. ## `applyChange(filePath)` -Handles a single filesystem change event. It is structured as a fast path with -early exits: +Handles a single filesystem change event. The changed page renders right +away; other affected pages are marked dirty and render on their next request +(`renderIfDirtyByOutPath`). `IncrementalBuildResult.markedPages` counts them, +so the dev server can reload the browser. 1. **Ignore changes outside `contentDir`**: relative path starting with `..`. -2. **Config file change**: if `isSiteConfigFile` ➜ mark `fullRebuild = true`. -3. **Missing file (deletion)**: - - If a content entry exists and navigation is enabled ➜ rebuild navigation - for the affected subtree, update manifest, return. - - Otherwise delete the output(s), remove manifest entries, update counters. -4. **Content change**: - - If the file is content (or was previously treated as content): - - No signature change ➜ no-op. - - With navigation enabled ➜ rebuild navigation subtree. - - Otherwise rebuild the single page and update its manifest entry. -5. **Asset change**: - - If signature unchanged ➜ no-op. - - Otherwise copy static asset, update manifest entry. - -The intent is to keep work proportional to the change while preserving -navigation correctness when navigation is derived from content. - -## Navigation-sensitive rebuilds - -When navigation is enabled, a single content change can affect: - -- The changed page. -- Its ancestor folders (navigation nodes). -- Any siblings within the affected folder scope. - -`rebuildNavigationForChange` therefore: - -1. Re-discovers content and rebuilds the full navigation tree. -2. Computes affected folders up to `navigationConfig.maxDepth`. -3. Rebuilds all pages in those folders. -4. Deletes pages in those folders that no longer exist. - -This favors correctness over minimal work whenever navigation structure is -content-derived. +2. **`siteConfig.json`** ➜ `fullRebuild = true`. Config shapes every output. +3. **Header or footer partial** (edit, add, or delete) ➜ compile the partials + again and mark only the pages whose nearest header or footer HTML changed. +4. **`_nav.json`** ➜ rebuild navigation; mark every page only if it changed. +5. **Content change**: no signature change ➜ no-op. Otherwise render the page, + refresh the content index and navigation, and mark pages as below. +6. **Content deletion**: remove the output and manifest entry, then refresh. +7. **Asset change**: copy the asset and update its manifest entry. A `.ts` + change rebuilds the script bundles that import it. + +## What marks other pages + +- **Navigation changed** ➜ every page, since every page shows navigation. + Navigation is compared as data, so an edit that leaves titles, order, and + structure alone marks nothing. +- **A page or asset was added or removed** ➜ every page, since any content URL + may now resolve differently or fail. +- **A header or footer changed** ➜ the pages that show it. +- **A script bundle's hashed name changed** ➜ the pages that load it, rendered + right away. ## Manifest assembly diff --git a/packages/ts-ssg/src/build/incremental/incremental.test.ts b/packages/ts-ssg/src/build/incremental/incremental.test.ts index 5368e82c..ddcac405 100644 --- a/packages/ts-ssg/src/build/incremental/incremental.test.ts +++ b/packages/ts-ssg/src/build/incremental/incremental.test.ts @@ -3,13 +3,13 @@ import path from 'node:path' import { disableLogger, getLogger, type Logger } from 'logpot' import { afterAll, beforeAll, describe, expect, it } from 'vitest' - import { resolveSiteConfig } from '../../config/config' +import { makeRepoTempDir } from '../../test/repoTempDir' import { createEmptyManifest, readManifest, writeManifest } from '../manifest' import { createIncrementalBuilder } from './index' async function withTempDir(worker: (dir: string) => Promise) { - const base = await fs.mkdtemp(path.join(process.cwd(), '.tmp-ts-ssg-')) + const base = await makeRepoTempDir('.tmp-ts-ssg-') try { return await worker(base) } finally { @@ -426,36 +426,62 @@ describe('incremental builder', () => { }) }) + async function createSite( + base: string, + mode: 'auto' | 'hybrid' | 'none', + files: Record, + ) { + const contentDir = path.join(base, 'content') + const outDir = path.join(base, 'out') + const write = async (relPath: string, contents: string) => { + const filePath = path.join(contentDir, relPath) + await fs.mkdir(path.dirname(filePath), { recursive: true }) + await fs.writeFile(filePath, contents, 'utf8') + return filePath + } + for (const [relPath, contents] of Object.entries(files)) { + await write(relPath, contents) + } + const builder = await createIncrementalBuilder({ + siteConfig: { + rootDir: base, + contentDir, + outDir, + navigation: { mode }, + }, + options: { writeErrorPages: true }, + }) + await builder.buildAll('initial') + const outPath = (urlPath: string) => + path.join(outDir, ...urlPath.split('/').filter(Boolean), 'index.html') + return { + builder, + contentDir, + outPath, + read: (urlPath: string) => fs.readFile(outPath(urlPath), 'utf8'), + change: async (relPath: string, contents: string) => + builder.applyChange(await write(relPath, contents)), + remove: async (relPath: string) => { + const filePath = path.join(contentDir, relPath) + await fs.rm(filePath) + return builder.applyChange(filePath) + }, + renderIfDirty: (urlPath: string) => + builder.renderIfDirtyByOutPath(outPath(urlPath)), + } + } + describe('content links', () => { async function createLinkSite( base: string, mode: 'auto' | 'none', files: Record, ) { - const contentDir = path.join(base, 'content') - const outDir = path.join(base, 'out') - for (const [relPath, contents] of Object.entries(files)) { - await fs.mkdir(path.dirname(path.join(contentDir, relPath)), { - recursive: true, - }) - await fs.writeFile(path.join(contentDir, relPath), contents, 'utf8') - } - const builder = await createIncrementalBuilder({ - siteConfig: { - rootDir: base, - contentDir, - outDir, - navigation: { mode }, - }, - options: { writeErrorPages: true }, - }) - await builder.buildAll('initial') - const indexOutPath = path.join(outDir, 'index.html') + const site = await createSite(base, mode, files) return { - builder, - contentDir, - indexOutPath, - readIndex: () => fs.readFile(indexOutPath, 'utf8'), + ...site, + indexOutPath: site.outPath('/'), + readIndex: () => site.read('/'), } } @@ -610,4 +636,133 @@ describe('incremental builder', () => { }) }) }) + + describe('shared partials and navigation', () => { + it.each(['auto', 'none'] as const)( + 'marks only the pages under an edited folder header (navigation %s)', + async (mode) => { + await withTempDir(async (base) => { + const site = await createSite(base, mode, { + 'index.mdx': '# Home', + 'guides/header.mdx': '

Guides header v1

', + 'guides/a.mdx': '# A', + 'guides/deep/header.mdx': '

Deep header

', + 'guides/deep/b.mdx': '# B', + 'components/c.mdx': '# C', + }) + + const result = await site.change( + 'guides/header.mdx', + '

Guides header v2

', + ) + + expect(result.fullRebuild).toBe(false) + expect(result.changedPages).toBe(0) + expect(result.markedPages).toBe(1) + expect(await site.renderIfDirty('/guides/a/')).toBe(true) + expect(await site.read('/guides/a/')).toContain('Guides header v2') + expect(await site.renderIfDirty('/guides/deep/b/')).toBe(false) + expect(await site.renderIfDirty('/components/c/')).toBe(false) + expect(await site.renderIfDirty('/')).toBe(false) + }) + }, + ) + + it('marks the pages that gain or lose a folder header', async () => { + await withTempDir(async (base) => { + const site = await createSite(base, 'none', { + 'header.mdx': '

Root header

', + 'index.mdx': '# Home', + 'guides/a.mdx': '# A', + 'components/c.mdx': '# C', + }) + + const added = await site.change( + 'components/header.mdx', + '

Components header

', + ) + expect(added.markedPages).toBe(1) + expect(await site.renderIfDirty('/components/c/')).toBe(true) + expect(await site.read('/components/c/')).toContain('Components header') + expect(await site.renderIfDirty('/guides/a/')).toBe(false) + + const removed = await site.remove('components/header.mdx') + expect(removed.markedPages).toBe(1) + expect(await site.renderIfDirty('/components/c/')).toBe(true) + expect(await site.read('/components/c/')).toContain('Root header') + expect(await site.renderIfDirty('/')).toBe(false) + }) + }) + + it('marks every page when the root footer changes', async () => { + await withTempDir(async (base) => { + const site = await createSite(base, 'none', { + 'footer.mdx': '

Footer v1

', + 'index.mdx': '# Home', + 'guides/a.mdx': '# A', + }) + + const result = await site.change('footer.mdx', '

Footer v2

') + + expect(result.fullRebuild).toBe(false) + expect(result.markedPages).toBe(2) + expect(await site.renderIfDirty('/')).toBe(true) + expect(await site.renderIfDirty('/guides/a/')).toBe(true) + expect(await site.read('/guides/a/')).toContain('Footer v2') + }) + }) + + it('marks other pages only when an edit changes navigation', async () => { + await withTempDir(async (base) => { + const site = await createSite(base, 'auto', { + 'index.mdx': '# Home', + 'a.mdx': ['---', 'title: A', '---', 'First draft.'].join('\n'), + 'b.mdx': '# B', + }) + + const bodyEdit = await site.change( + 'a.mdx', + ['---', 'title: A', '---', 'Second draft.'].join('\n'), + ) + expect(bodyEdit.changedPages).toBe(1) + expect(bodyEdit.markedPages).toBe(0) + expect(await site.renderIfDirty('/b/')).toBe(false) + + const titleEdit = await site.change( + 'a.mdx', + ['---', 'title: Alpha', '---', 'Second draft.'].join('\n'), + ) + expect(titleEdit.changedPages).toBe(1) + expect(titleEdit.markedPages).toBeGreaterThan(0) + expect(await site.renderIfDirty('/b/')).toBe(true) + expect(await site.read('/b/')).toContain('Alpha') + }) + }) + + it('marks pages after a nav file edit only when navigation changes', async () => { + await withTempDir(async (base) => { + const site = await createSite(base, 'hybrid', { + 'index.mdx': '# Home', + 'a.mdx': '# A', + 'b.mdx': '# B', + '_nav.json': '{"sequence":["a.mdx","b.mdx"]}', + }) + + const formatting = await site.change( + '_nav.json', + '{ "sequence": [ "a.mdx", "b.mdx" ] }\n', + ) + expect(formatting.changedPages).toBe(0) + expect(formatting.markedPages).toBe(0) + + const reorder = await site.change( + '_nav.json', + '{"sequence":["b.mdx","a.mdx"]}', + ) + expect(reorder.changedPages).toBe(0) + expect(reorder.markedPages).toBeGreaterThan(0) + expect(await site.renderIfDirty('/a/')).toBe(true) + }) + }) + }) }) diff --git a/packages/ts-ssg/src/build/incremental/index.ts b/packages/ts-ssg/src/build/incremental/index.ts index ab5b1701..abc1714c 100644 --- a/packages/ts-ssg/src/build/incremental/index.ts +++ b/packages/ts-ssg/src/build/incremental/index.ts @@ -355,16 +355,14 @@ class IncrementalRuntime { result.fullRebuild = true return result } - if (isDefaultFooterFile(relPath)) { - result.fullRebuild = true - return result - } - if (isDefaultHeaderFile(relPath)) { - result.fullRebuild = true - return result - } - await this.changeApplier.applyFileChange(filePath, relPath, result) + this.contentState.takeMarkedPageCount() + if (isDefaultHeaderFile(relPath) || isDefaultFooterFile(relPath)) { + await this.contentState.refreshPartials() + } else { + await this.changeApplier.applyFileChange(filePath, relPath, result) + } + result.markedPages = this.contentState.takeMarkedPageCount() return result } diff --git a/packages/ts-ssg/src/build/incremental/types.ts b/packages/ts-ssg/src/build/incremental/types.ts index f53a2c62..3eddfcde 100644 --- a/packages/ts-ssg/src/build/incremental/types.ts +++ b/packages/ts-ssg/src/build/incremental/types.ts @@ -6,6 +6,8 @@ export interface IncrementalBuildResult { changedAssets: number deletedPages: number deletedAssets: number + /** Pages marked to render again on their next request. */ + markedPages: number reason: string } diff --git a/packages/ts-ssg/src/build/page.ts b/packages/ts-ssg/src/build/page.ts index 6eeae404..a0771efc 100644 --- a/packages/ts-ssg/src/build/page.ts +++ b/packages/ts-ssg/src/build/page.ts @@ -358,24 +358,27 @@ async function renderPageShell(input: RenderPageShellInput): Promise { navigation, outline, pageInfo, - headerHtml: resolveSpecialHtmlForPage( - pageInfo.relPath, - context.headerHtmlByDir, - ), - footerHtml: resolveFooterHtmlForPage( - pageInfo.relPath, - context.footerHtmlByDir, - ), + ...resolvePagePartials(context, pageInfo), consent: context.config.consent, analytics: context.config.analytics, }) } -function resolveFooterHtmlForPage( - pageRelPath: string, - footerHtmlByDir: Map | undefined, -): string | undefined { - return resolveSpecialHtmlForPage(pageRelPath, footerHtmlByDir) +/** The header and footer a page shows: the nearest ones up its folders. */ +export function resolvePagePartials( + context: Pick, + page: { relPath: string }, +) { + return { + headerHtml: resolveSpecialHtmlForPage( + page.relPath, + context.headerHtmlByDir, + ), + footerHtml: resolveSpecialHtmlForPage( + page.relPath, + context.footerHtmlByDir, + ), + } } function resolveSpecialHtmlForPage( diff --git a/packages/ts-ssg/src/dev/live-reload.ts b/packages/ts-ssg/src/dev/live-reload.ts index 54fd1462..2ffdda44 100644 --- a/packages/ts-ssg/src/dev/live-reload.ts +++ b/packages/ts-ssg/src/dev/live-reload.ts @@ -141,12 +141,6 @@ export function injectLiveReload( `const parseJSON = (value) => {` + `try { return JSON.parse(value); } catch { return null; }` + `};` + - `const normalize = (value) => {` + - `if (!value) return '/';` + - `let next = value.startsWith('/') ? value : '/' + value;` + - `if (next.length > 1 && next.endsWith('/')) next = next.slice(0, -1);` + - `return next;` + - `};` + `const source = new EventSource('${endpoint}');` + `source.addEventListener('state', (event) => {` + `const payload = parseJSON(event.data);` + @@ -154,10 +148,6 @@ export function injectLiveReload( `if (!Number.isFinite(next)) return;` + `if (next > pageVersion) location.reload();` + `});` + - `source.addEventListener('page-rendered', (event) => {` + - `const payload = parseJSON(event.data);` + - `if (normalize(payload?.path) === normalize(location.pathname)) location.reload();` + - `});` + `})();` + `` const withVersion = snippet.replace('__PAGE_VERSION__', String(version)) diff --git a/packages/ts-ssg/src/dev/server.test.ts b/packages/ts-ssg/src/dev/server.test.ts new file mode 100644 index 00000000..8a125349 --- /dev/null +++ b/packages/ts-ssg/src/dev/server.test.ts @@ -0,0 +1,138 @@ +import fs from 'node:fs/promises' +import http from 'node:http' +import net from 'node:net' +import os from 'node:os' +import path from 'node:path' +import { disableLogger } from 'logpot' +import { afterEach, beforeAll, describe, expect, it } from 'vitest' +import { type DevServerHandle, startDevServer } from './server' + +const HOST = '127.0.0.1' +const LIVE_RELOAD_PATH = '/__ts-ssg/events' + +describe('dev server', () => { + let root: string | undefined + let server: DevServerHandle | undefined + let liveReload: LiveReloadListener | undefined + + beforeAll(() => { + disableLogger() + }) + + afterEach(async () => { + liveReload?.close() + await server?.close() + if (root) await fs.rm(root, { recursive: true, force: true }) + }) + + it('serves the new header on the reload a header edit triggers', async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), 'ts-ssg-dev-')) + const contentDir = path.join(root, 'content') + const headerPath = path.join(contentDir, 'guides', 'header.mdx') + await writeFile(headerPath, '

Header v1

') + await writeFile(path.join(contentDir, 'guides', 'index.mdx'), '# Guides') + await writeFile(path.join(contentDir, 'index.mdx'), '# Home') + + const port = await findFreePort() + server = await startDevServer({ + host: HOST, + port, + build: { + siteConfig: { + rootDir: root, + contentDir, + outDir: path.join(root, 'out'), + }, + }, + }) + liveReload = listenForLiveReload(port) + // The initial build ends with the first reload signal. + await liveReload.reachVersion(1) + expect(await get(port, '/guides/')).toContain('Header v1') + + await writeFile(headerPath, '

Header v2

') + await liveReload.reachVersion(2) + + // The one request a browser makes after the reload signal must already + // show the change; no later signal announces a background render. + expect(await get(port, '/guides/')).toContain('Header v2') + }, 20_000) +}) + +type LiveReloadListener = ReturnType + +/** Follows the dev server's live reload version, like the browser script. */ +function listenForLiveReload(port: number) { + let version = -1 + const waiters: Array<{ target: number; resolve: () => void }> = [] + const request = http.get( + { host: HOST, port, path: LIVE_RELOAD_PATH }, + (res) => { + let buffer = '' + res.setEncoding('utf8') + res.on('data', (chunk: string) => { + buffer += chunk + const messages = buffer.split('\n\n') + buffer = messages.pop() ?? '' + for (const message of messages) { + if (!message.startsWith('event: state')) continue + const data = message + .split('\n') + .find((line) => line.startsWith('data: ')) + version = Number(JSON.parse(data?.slice(6) ?? '{}').version) + for (const waiter of waiters.filter( + (entry) => version >= entry.target, + )) { + waiters.splice(waiters.indexOf(waiter), 1) + waiter.resolve() + } + } + }) + }, + ) + request.on('error', () => {}) + return { + reachVersion(target: number) { + if (version >= target) return Promise.resolve() + return new Promise((resolve) => waiters.push({ target, resolve })) + }, + close() { + request.destroy() + }, + } +} + +async function writeFile(filePath: string, contents: string) { + await fs.mkdir(path.dirname(filePath), { recursive: true }) + await fs.writeFile(filePath, contents, 'utf8') +} + +function findFreePort() { + return new Promise((resolve, reject) => { + const probe = net.createServer() + probe.once('error', reject) + probe.listen(0, HOST, () => { + const address = probe.address() + probe.close(() => + typeof address === 'object' && address + ? resolve(address.port) + : reject(new Error('No port assigned.')), + ) + }) + }) +} + +function get(port: number, pathname: string) { + return new Promise((resolve, reject) => { + http + .get({ host: HOST, port, path: pathname }, (res) => { + let body = '' + res.setEncoding('utf8') + res.on('data', (chunk: string) => { + body += chunk + }) + res.on('end', () => resolve(body)) + }) + .on('error', reject) + }) +} diff --git a/packages/ts-ssg/src/dev/server.ts b/packages/ts-ssg/src/dev/server.ts index 6191a009..472d7c9f 100644 --- a/packages/ts-ssg/src/dev/server.ts +++ b/packages/ts-ssg/src/dev/server.ts @@ -82,7 +82,6 @@ export async function startDevServer( const { host, port, watch, liveReload } = resolveDevServerOptions(input) const clients: LiveReloadClients = new Map() - const backgroundRenderTasks = new Map>() let liveReloadVersion = 0 let initialBuildDone = false let shuttingDown = false @@ -103,11 +102,6 @@ export async function startDevServer( emitState(reason) } - const notifyPageRendered = (pathname: string) => { - if (!liveReload) return - broadcastJson(clients, 'page-rendered', { path: pathname }) - } - const scheduleRebuild = (reason: string, filePath?: string) => { if (shuttingDown) return requestState.reason = reason @@ -193,7 +187,8 @@ export async function startDevServer( change.changedPages > 0 || change.changedAssets > 0 || change.deletedPages > 0 || - change.deletedAssets > 0 + change.deletedAssets > 0 || + change.markedPages > 0 } if (requiresFull) { await rebuild(requestState.reason, { recreateBuilder: true }) @@ -212,11 +207,10 @@ export async function startDevServer( basePath: config.basePath, i18n: config.i18n, liveReload, - incremental, + // A full rebuild replaces the builder, so requests ask for the current one. + getIncremental: () => incremental, clients, getLiveReloadVersion: () => liveReloadVersion, - backgroundRenderTasks, - notifyPageRendered, log, }), ) @@ -303,11 +297,9 @@ type DevServerRequestHandlerInput = { basePath: string i18n: I18nConfig liveReload: boolean - incremental: IncrementalBuilder + getIncremental: () => IncrementalBuilder clients: LiveReloadClients getLiveReloadVersion: () => number - backgroundRenderTasks: Map> - notifyPageRendered: (pathname: string) => void log: Logger } @@ -319,15 +311,14 @@ function createDevServerRequestHandler(input: DevServerRequestHandlerInput) { basePath, i18n, liveReload, - incremental, + getIncremental, clients, getLiveReloadVersion, - backgroundRenderTasks, - notifyPageRendered, log, } = input return async (req: http.IncomingMessage, res: http.ServerResponse) => { + const incremental = getIncremental() if (!req.url) { res.writeHead(400) res.end() @@ -376,13 +367,10 @@ function createDevServerRequestHandler(input: DevServerRequestHandlerInput) { res, filePath: fileResult.filePath, ext: fileResult.ext, - publicPathname: pathname, liveReloadPath, liveReload, liveReloadVersion, incremental, - backgroundRenderTasks, - notifyPageRendered, log, }) } @@ -458,13 +446,10 @@ type ServeResolvedFileInput = { res: http.ServerResponse filePath: string ext: string - publicPathname: string liveReloadPath: string liveReload: boolean liveReloadVersion: number incremental: IncrementalBuilder - backgroundRenderTasks: Map> - notifyPageRendered: (pathname: string) => void log: Logger } @@ -474,30 +459,22 @@ async function serveResolvedFile(input: ServeResolvedFileInput): Promise { res, filePath, ext, - publicPathname, liveReloadPath, liveReload, liveReloadVersion, incremental, - backgroundRenderTasks, - notifyPageRendered, log, } = input try { if (ext === '.html') { + // A page marked dirty renders before it is served, so the response + // always reflects the latest change. + await incremental.renderIfDirtyByOutPath(filePath) const html = await fsPromises.readFile(filePath, 'utf8') const injected = liveReload ? injectLiveReload(html, liveReloadPath, liveReloadVersion) : html writeHtmlResponse(res, 200, injected) - queueBackgroundRender({ - filePath, - pathname: publicPathname, - incremental, - backgroundRenderTasks, - notifyPageRendered, - log, - }) return } @@ -508,41 +485,3 @@ async function serveResolvedFile(input: ServeResolvedFileInput): Promise { logError(log, error, 'serve failed') } } - -type QueueBackgroundRenderInput = { - filePath: string - pathname: string - incremental: IncrementalBuilder - backgroundRenderTasks: Map> - notifyPageRendered: (pathname: string) => void - log: Logger -} - -function queueBackgroundRender(input: QueueBackgroundRenderInput) { - const { - filePath, - pathname, - incremental, - backgroundRenderTasks, - notifyPageRendered, - log, - } = input - const existing = backgroundRenderTasks.get(filePath) - if (existing) return - // One background render notification per output file prevents fan-out storms. - const task = incremental - .renderIfDirtyByOutPath(filePath) - .then((rendered) => { - if (rendered) { - notifyPageRendered(pathname) - } - }) - .catch((error) => { - logError(log, error, 'background render failed') - }) - .finally(() => { - backgroundRenderTasks.delete(filePath) - }) - backgroundRenderTasks.set(filePath, task) - void task -} diff --git a/packages/ts-ssg/src/test/repoTempDir.ts b/packages/ts-ssg/src/test/repoTempDir.ts new file mode 100644 index 00000000..5aad34f5 --- /dev/null +++ b/packages/ts-ssg/src/test/repoTempDir.ts @@ -0,0 +1,12 @@ +import fs from 'node:fs/promises' +import path from 'node:path' + +/** + * Creates a temporary folder under the repository's `.tmp/`, for tests whose + * fixtures must resolve the workspace's packages, such as bundled scripts. + */ +export async function makeRepoTempDir(prefix: string) { + const root = path.join(process.cwd(), '.tmp') + await fs.mkdir(root, { recursive: true }) + return fs.mkdtemp(path.join(root, prefix)) +}