Skip to content

Commit cc5ef69

Browse files
committed
fix(dev): hold the render in flight on the panel until it lands
1 parent 78ec84e commit cc5ef69

4 files changed

Lines changed: 140 additions & 29 deletions

File tree

packages/nuxt-cli/src/dev/tui/index.ts

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,10 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {})
146146
update({})
147147
}
148148

149+
// Progress writes the panel state itself; going through `update` is what arms
150+
// the animation for whatever it has just put there.
151+
session.onProgressChange(refresh)
152+
149153
function clearActivity(): void {
150154
update({ active: false })
151155
}
@@ -156,16 +160,22 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {})
156160
frame: (state.frame ?? 0) + 1,
157161
elapsedMs: working ? Date.now() - (state.loadStartedAt ?? sessionStart) : state.elapsedMs,
158162
phaseElapsedMs: working && state.phaseStartedAt !== undefined ? Date.now() - state.phaseStartedAt : state.phaseElapsedMs,
163+
renderingMs: state.rendering && Date.now() - state.rendering.startedAt,
159164
})
160165
}
161166

162-
/** Animate the mark only while the server or a task is working, on screen. */
167+
/**
168+
* Animate the mark only while something is in flight, on screen. A request
169+
* being rendered counts: the server is not loading, but it is the only thing
170+
* happening, and a still panel in front of a slow page reads as a hung one.
171+
*/
163172
function syncAnimation(): void {
164-
const working = (state.status !== 'ready' && state.status !== 'error' && !openOverlay()) || (!!state.task && !openOverlay())
165-
// Waiting on the first render is measured in seconds, sometimes tens of
166-
// them, which is too long to spend a build's frame rate on: the panel only
167-
// has to look alive.
168-
const interval = state.status === 'warming' ? LOGO_FRAME_MS * WARMUP_FRAME_RATIO : LOGO_FRAME_MS
173+
const busy = (state.status !== 'ready' && state.status !== 'error') || !!state.task || !!state.rendering
174+
const working = busy && !openOverlay()
175+
// Waiting on a render is measured in seconds, sometimes tens of them, which
176+
// is too long to spend a build's frame rate on: the panel only has to look
177+
// alive.
178+
const interval = state.status === 'warming' || state.rendering ? LOGO_FRAME_MS * WARMUP_FRAME_RATIO : LOGO_FRAME_MS
169179
if (working && animation && interval !== animationInterval) {
170180
clearInterval(animation)
171181
animation = undefined
@@ -283,7 +293,10 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {})
283293
})
284294

285295
context.onReady(() => {
286-
const warming = state.awaitingFirstRender === true
296+
// Whether anything is still being waited for is progress's to say: a ready
297+
// listener only knows the socket is up, and a server nobody has asked for a
298+
// page yet is not warming up, it is idle.
299+
const warming = state.status === 'warming'
287300
update({
288301
status: warming ? 'warming' : 'ready',
289302
note: warming ? state.note : undefined,
@@ -583,12 +596,10 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {})
583596
},
584597
setRoutes: payload => routeOverlay.setRoutes(payload),
585598
setRendering: (pending) => {
586-
// A build in flight is already the more interesting thing to report, and
587-
// the badge it shows must not be replaced by a request that outlives it.
588-
if (state.status !== 'ready') {
589-
return
590-
}
591-
update({ note: pending ? `rendering ${pending.label}` : undefined })
599+
update({
600+
rendering: pending && { label: pending.label, startedAt: pending.startedAt },
601+
renderingMs: pending && Date.now() - pending.startedAt,
602+
})
592603
},
593604
}
594605
}

packages/nuxt-cli/src/dev/tui/panel.ts

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,15 @@ export interface PanelState {
9494
active?: boolean
9595
/** Replaces the badge's standing description, for a restart reason. */
9696
note?: string
97+
/**
98+
* The request being rendered right now, and when it arrived. Held apart from
99+
* `note` and `status`, which belong to the load: a request in flight is not a
100+
* state of the load, and a load reporting itself ready again mid-render must
101+
* not take it off the panel.
102+
*/
103+
rendering?: { label: string, startedAt: number }
104+
/** How long that render has been going, for its ticking clock. */
105+
renderingMs?: number
97106
/**
98107
* Feedback in place of the badge's standing description. Passing unless it
99108
* carries a `label`, which marks something waiting on the user: the label
@@ -228,6 +237,13 @@ const TASK_FRAMES_ASCII = ['|', '/', '-', '\\'] as const
228237
/** How long a phase runs before its own elapsed time is worth a mention. */
229238
const PHASE_ELAPSED_THRESHOLD = 2500
230239

240+
/**
241+
* How long a render runs before its own clock is worth showing. Lower than a
242+
* phase's, because a render is only reported once it has already been in flight
243+
* long enough to notice, and the clock is the only thing that moves.
244+
*/
245+
const RENDER_ELAPSED_THRESHOLD = 1000
246+
231247
function renderProgress(state: PanelState, columns: number): string {
232248
const fraction = Math.min(1, Math.max(0, state.progress ?? 0))
233249
const filled = Math.round(fraction * PROGRESS_BAR_WIDTH)
@@ -315,10 +331,31 @@ function renderStatus(state: PanelState, columns: number): string {
315331
)
316332
}
317333

318-
const badge = BADGES[state.status]
319-
const description = state.notice ? renderNotice(state) : styleText(MUTED, decapitalise(state.note || badge.note) + renderPhaseElapsed(state))
334+
// A render is only worth reporting over a server with nothing else to say;
335+
// a load in flight is the more important thing and keeps the line.
336+
const rendering = state.status === 'ready' || state.status === 'warming' ? state.rendering : undefined
337+
const badge = rendering && state.awaitingFirstRender ? BADGES.warming : BADGES[state.status]
338+
const description = state.notice
339+
? renderNotice(state)
340+
: rendering
341+
? styleText(MUTED, `rendering ${rendering.label}${renderRenderElapsed(state)}`)
342+
: styleText(MUTED, decapitalise(state.note || badge.note) + renderPhaseElapsed(state))
320343
const head = ` ${styleText(badge.style, ` ${badge.label} `)} ${description}`
321-
return truncate(head + renderTicker(state, columns - visibleWidth(head)), columns)
344+
// The request in flight is the more interesting one, and it is usually the
345+
// same URL as the last: printing both reads as a stutter.
346+
return truncate(head + (rendering ? '' : renderTicker(state, columns - visibleWidth(head))), columns)
347+
}
348+
349+
/**
350+
* How long the render in flight has taken. The only thing that moves on a panel
351+
* whose server is up and waiting on a page, so it is what says the wait is
352+
* progressing rather than stuck.
353+
*/
354+
function renderRenderElapsed(state: PanelState): string {
355+
if (state.renderingMs === undefined || state.renderingMs < RENDER_ELAPSED_THRESHOLD) {
356+
return ''
357+
}
358+
return ` \u00B7 ${(state.renderingMs / 1000).toFixed(1)}s`
322359
}
323360

324361
/**

packages/nuxt-cli/src/dev/tui/session.ts

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { consola } from 'consola'
1212
import { KEEPS_PROCESS_ALIVE } from '../../utils/errors'
1313
import { debug, isEmittingCliLog, setLoggerImpl } from '../../utils/logger'
1414
import { getPkgVersion } from '../../utils/pkg'
15+
import { READY_MESSAGE } from '../../utils/progress-snapshot'
1516
import { startupElapsedMs } from '../../utils/startup-clock'
1617
import { resolveBackground } from '../../utils/terminal-theme'
1718
import { currentRequest, isServingRequest } from '../serving-state'
@@ -86,6 +87,12 @@ export interface DevUISession {
8687
stopStartupTicker: () => void
8788
/** Narrate the current startup phase while the server is loading. */
8889
reportProgress: (snapshot: ProgressSnapshot) => void
90+
/**
91+
* Repaint through the controller instead of {@link render} whenever progress
92+
* changes what is on the panel, so the controller can re-arm the animation it
93+
* owns: a render in flight is work, and a still panel reads as a hung one.
94+
*/
95+
onProgressChange: (listener: () => void) => void
8996
/**
9097
* Show the bound address the moment the socket answers, spinning until the
9198
* resolved config confirms it. The full URL block replaces it on ready.
@@ -176,22 +183,36 @@ export function beginDevUI(options: DevUISupportOptions & { version?: string, cw
176183
surface.render(renderPanel(state, process.stdout.columns || 80, process.stdout.rows || 24))
177184
}
178185

186+
let progressListener: (() => void) | undefined
187+
188+
/** Repaint through the controller where one is attached, so it sees the change. */
189+
function repaint(): void {
190+
if (progressListener) {
191+
progressListener()
192+
return
193+
}
194+
render()
195+
}
196+
179197
function reportProgress(snapshot: ProgressSnapshot): void {
180198
if (snapshot.status === 'ready') {
181199
// Between the server accepting requests and answering one there is
182200
// nothing to watch but a badge, so it says which of the two has happened.
183-
// A render in flight is the only thing worth waiting for at this point,
184-
// and until the first one lands nothing else is being reported at all.
185-
const rendering = !!snapshot.pending && !snapshot.serving
186-
state.awaitingFirstRender = rendering
187-
state.note = snapshot.pending ? `rendering ${snapshot.pending.label}` : undefined
188-
state.progress = rendering ? snapshot.progress : undefined
201+
// Something already waiting for a page is a state of the load; the request
202+
// being rendered is not, and is held separately so that a load reporting
203+
// itself ready again cannot take it off the panel.
204+
const waiting = !snapshot.serving && snapshot.message !== READY_MESSAGE
205+
state.awaitingFirstRender = !snapshot.serving
206+
state.note = waiting ? snapshot.message : undefined
207+
state.progress = waiting ? snapshot.progress : undefined
208+
state.rendering = snapshot.pending && { label: snapshot.pending.label, startedAt: snapshot.pending.startedAt }
209+
state.renderingMs = snapshot.pending && Date.now() - snapshot.pending.startedAt
189210
state.phaseStartedAt = undefined
190211
state.phaseElapsedMs = undefined
191212
if (state.status === 'ready' || state.status === 'warming') {
192-
state.status = rendering ? 'warming' : 'ready'
193-
render()
213+
state.status = waiting ? 'warming' : 'ready'
194214
}
215+
repaint()
195216
return
196217
}
197218
if (snapshot.status !== 'loading') {
@@ -413,6 +434,9 @@ export function beginDevUI(options: DevUISupportOptions & { version?: string, cw
413434
expectRender,
414435
stopStartupTicker,
415436
reportProgress,
437+
onProgressChange: (listener) => {
438+
progressListener = listener
439+
},
416440
reportListening,
417441
teardown,
418442
onTeardown: task => void teardownTasks.push(task),

packages/nuxt-cli/test/unit/dev-tui.spec.ts

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,12 +74,38 @@ describe('dev tui panel', () => {
7474
expect(lines.join('\n')).not.toContain('READY')
7575
})
7676

77-
it('should say which request it is busy with', () => {
78-
const warming = renderPanel({ ...READY, status: 'warming', awaitingFirstRender: true, note: 'rendering GET /' }, 80, 30).map(strip)
79-
expect(warming.join('\n')).toContain('WARMUP rendering GET /')
77+
it('should say which request it is busy with, and for how long', () => {
78+
const first = renderPanel({ ...READY, awaitingFirstRender: true, rendering: { label: 'GET /', startedAt: 0 }, renderingMs: 6400 }, 80, 30).map(strip)
79+
expect(first.join('\n')).toContain('WARMUP rendering GET / · 6.4s')
8080

81-
const ready = renderPanel({ ...READY, note: 'rendering GET /about' }, 80, 30).map(strip)
82-
expect(ready.join('\n')).toContain('READY rendering GET /about')
81+
const later = renderPanel({ ...READY, rendering: { label: 'GET /about', startedAt: 0 }, renderingMs: 1200 }, 80, 30).map(strip)
82+
expect(later.join('\n')).toContain('READY rendering GET /about · 1.2s')
83+
})
84+
85+
it('should keep the last request off the line while one is in flight', () => {
86+
const lines = renderPanel({
87+
...READY,
88+
rendering: { label: 'GET /', startedAt: 0 },
89+
renderingMs: 6700,
90+
lastRequest: { method: 'GET', url: '/', status: 200, duration: 8442 },
91+
}, 100, 30).map(strip)
92+
93+
expect(lines.join('\n')).toContain('READY rendering GET / · 6.7s')
94+
expect(lines.join('\n')).not.toContain('8442ms')
95+
})
96+
97+
it('should not put a clock on a render that has only just arrived', () => {
98+
const lines = renderPanel({ ...READY, rendering: { label: 'GET /', startedAt: 0 }, renderingMs: 40 }, 80, 30).map(strip)
99+
100+
expect(lines.join('\n')).toContain('READY rendering GET /')
101+
expect(lines.join('\n')).not.toContain('0.0s')
102+
})
103+
104+
it('should let a load in flight keep the status line from a render', () => {
105+
const lines = renderPanel({ ...READY, status: 'building', note: 'nuxt.config.ts changed', rendering: { label: 'GET /', startedAt: 0 }, renderingMs: 6400 }, 80, 30).map(strip)
106+
107+
expect(lines.join('\n')).toContain('BUILDING nuxt.config.ts changed')
108+
expect(lines.join('\n')).not.toContain('rendering GET /')
83109
})
84110

85111
it('should not keep claiming how fast the last load was while rebuilding', () => {
@@ -2315,6 +2341,19 @@ describe('request failures on the panel', () => {
23152341
})
23162342
})
23172343

2344+
it('should keep reporting a render when the server reports itself ready again', async () => {
2345+
await withPanel(async (ui, settle) => {
2346+
ui.setStatus('ready')
2347+
ui.setRendering({ label: 'GET /', startedAt: Date.now() })
2348+
// The bundler reloads while it serves the first document, which is a
2349+
// `building` event either side of the render it is serving.
2350+
ui.setStatus('building')
2351+
ui.setStatus('ready')
2352+
2353+
expect(await settle()).toContain('rendering GET /')
2354+
})
2355+
})
2356+
23182357
it('should report a failed app request', async () => {
23192358
await withPanel(async (ui, settle) => {
23202359
ui.setStatus('ready')

0 commit comments

Comments
 (0)