Skip to content

Commit 12a176c

Browse files
committed
fix: serialize theme preview uploads to avoid out-of-order reloads
Chokidar does not await async listeners, so rapid saves were kicking off concurrent PUT /local_preview uploads. The reload broadcast fired whenever each request resolved, which could leave the browser reloading on a stale state. Gate uploads on a single in-flight flag and coalesce events received during a run via a rerun flag that retriggers once the current upload finishes.
1 parent 277239d commit 12a176c

2 files changed

Lines changed: 46 additions & 3 deletions

File tree

packages/zcli-themes/src/commands/themes/preview.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,12 @@ export default class Preview extends Command {
9999
`${themePath}/style.css`
100100
]
101101

102-
const handleThemeChange = async (path: string) => {
103-
this.log(chalk.bold('Change'), path)
102+
let previewing = false
103+
let rerun = false
104+
105+
const runPreview = async () => {
106+
previewing = true
107+
rerun = false
104108
try {
105109
await preview(themePath, flags)
106110
wss.clients.forEach((client) => {
@@ -110,9 +114,18 @@ export default class Preview extends Command {
110114
})
111115
} catch (e) {
112116
this.error(e as Error, { exit: false })
117+
} finally {
118+
previewing = false
119+
if (rerun) runPreview()
113120
}
114121
}
115122

123+
const handleThemeChange = (path: string) => {
124+
this.log(chalk.bold('Change'), path)
125+
if (previewing) rerun = true
126+
else runPreview()
127+
}
128+
116129
const watcher = chokidar.watch(monitoredPaths, { ignoreInitial: true })
117130
.on('add', handleThemeChange)
118131
.on('change', handleThemeChange)

packages/zcli-themes/tests/functional/preview.test.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,15 +86,23 @@ describe('themes:preview', function () {
8686
call.args[0]?.method === 'PUT'
8787
).length
8888

89-
const waitForUploadCount = async (expected: number, timeoutMs = 2000) => {
89+
const waitForUploadCount = async (expected: number, timeoutMs = 5000) => {
9090
const start = Date.now()
9191
while (previewUploadCalls() < expected && Date.now() - start < timeoutMs) {
9292
await new Promise(resolve => setTimeout(resolve, 50))
9393
}
9494
}
9595

96+
// chokidar may not have finished its initial scan when the test body
97+
// runs. Wait until its events become observable before triggering the
98+
// fs operations under test.
99+
const waitForWatcherReady = async () => {
100+
await new Promise(resolve => setTimeout(resolve, 200))
101+
}
102+
96103
preview
97104
.it('should revalidate when a new template partial is added', async () => {
105+
await waitForWatcherReady()
98106
const initialCalls = previewUploadCalls()
99107
const partialsDir = path.join(baseThemePath, 'templates/partials')
100108
const partialPath = path.join(partialsDir, 'foo.hbs')
@@ -110,6 +118,7 @@ describe('themes:preview', function () {
110118

111119
preview
112120
.it('should revalidate when an asset is deleted', async () => {
121+
await waitForWatcherReady()
113122
const assetPath = path.join(baseThemePath, 'assets/bike.png')
114123
const assetContent = fs.readFileSync(assetPath)
115124
const initialCalls = previewUploadCalls()
@@ -121,6 +130,27 @@ describe('themes:preview', function () {
121130
fs.writeFileSync(assetPath, assetContent)
122131
}
123132
})
133+
134+
preview
135+
.it('should serialize rapid successive changes instead of uploading concurrently', async () => {
136+
await waitForWatcherReady()
137+
const initialCalls = previewUploadCalls()
138+
const partialsDir = path.join(baseThemePath, 'templates/partials')
139+
fs.mkdirSync(partialsDir, { recursive: true })
140+
const paths = ['a.hbs', 'b.hbs', 'c.hbs'].map(name => path.join(partialsDir, name))
141+
try {
142+
for (const p of paths) fs.writeFileSync(p, '<div/>')
143+
await waitForUploadCount(initialCalls + 1)
144+
// Let any queued rerun finish; a working serializer produces at
145+
// most one in-flight upload plus one queued rerun.
146+
await new Promise(resolve => setTimeout(resolve, 500))
147+
const calls = previewUploadCalls() - initialCalls
148+
expect(calls).to.be.greaterThan(0)
149+
expect(calls).to.be.at.most(2)
150+
} finally {
151+
fs.rmSync(partialsDir, { recursive: true, force: true })
152+
}
153+
})
124154
})
125155

126156
describe('validation errors', () => {

0 commit comments

Comments
 (0)