1use crate::types::{
10 NextjsBootstrapPhase, NextjsBootstrapTransitionError, NextjsNavigationType,
11 NextjsRenderEnvironment, validate_bootstrap_transition,
12};
13use serde::{Deserialize, Serialize};
14use std::collections::BTreeMap;
15use thiserror::Error;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum BootstrapRecoveryAction {
21 None,
23 ResetToHydrating,
25 RetryRuntimeInit,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum BootstrapCommand {
33 BeginHydration,
35 CompleteHydration,
37 InitializeRuntime,
39 RuntimeInitFailed {
41 reason: String,
43 },
44 CancelBootstrap {
46 reason: String,
48 },
49 HydrationMismatch {
51 reason: String,
53 },
54 Recover {
56 action: BootstrapRecoveryAction,
58 },
59 Navigate {
61 nav: NextjsNavigationType,
63 route_segment: String,
65 },
66 HotReload,
68 CacheRevalidated,
70}
71
72impl BootstrapCommand {
73 fn name(&self) -> &'static str {
74 match self {
75 Self::BeginHydration => "begin_hydration",
76 Self::CompleteHydration => "complete_hydration",
77 Self::InitializeRuntime => "initialize_runtime",
78 Self::RuntimeInitFailed { .. } => "runtime_init_failed",
79 Self::CancelBootstrap { .. } => "cancel_bootstrap",
80 Self::HydrationMismatch { .. } => "hydration_mismatch",
81 Self::Recover { .. } => "recover",
82 Self::Navigate { .. } => "navigate",
83 Self::HotReload => "hot_reload",
84 Self::CacheRevalidated => "cache_revalidated",
85 }
86 }
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub struct BootstrapLogEvent {
92 pub action: String,
94 pub from_phase: NextjsBootstrapPhase,
96 pub to_phase: NextjsBootstrapPhase,
98 pub from_environment: NextjsRenderEnvironment,
100 pub to_environment: NextjsRenderEnvironment,
102 pub route_segment: String,
104 pub recovery_action: BootstrapRecoveryAction,
106 pub detail: Option<String>,
108}
109
110impl BootstrapLogEvent {
111 #[must_use]
113 pub fn as_log_fields(&self) -> BTreeMap<String, String> {
114 let mut fields = BTreeMap::new();
115 fields.insert("action".to_string(), self.action.clone());
116 fields.insert(
117 "from_environment".to_string(),
118 format!("{:?}", self.from_environment),
119 );
120 fields.insert("from_phase".to_string(), format!("{:?}", self.from_phase));
121 fields.insert(
122 "recovery_action".to_string(),
123 format!("{:?}", self.recovery_action),
124 );
125 fields.insert("route_segment".to_string(), self.route_segment.clone());
126 fields.insert(
127 "to_environment".to_string(),
128 format!("{:?}", self.to_environment),
129 );
130 fields.insert("to_phase".to_string(), format!("{:?}", self.to_phase));
131 if let Some(detail) = &self.detail {
132 fields.insert("detail".to_string(), detail.clone());
133 }
134 fields
135 }
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140pub struct NextjsBootstrapSnapshot {
141 pub phase: NextjsBootstrapPhase,
143 pub environment: NextjsRenderEnvironment,
145 pub route_segment: String,
147 pub runtime_initialized: bool,
149 pub runtime_init_attempts: u32,
151 pub runtime_init_successes: u32,
153 pub runtime_failure_count: u32,
155 pub cancellation_count: u32,
161 pub hydration_mismatch_count: u32,
163 pub soft_navigation_count: u32,
165 pub hard_navigation_count: u32,
167 pub popstate_navigation_count: u32,
169 pub cache_revalidation_count: u32,
171 pub scope_invalidation_count: u32,
173 pub runtime_reinit_required_count: u32,
175 pub active_scope_generation: u32,
179 pub last_invalidated_scope_generation: Option<u32>,
181 pub hot_reload_count: u32,
183 pub last_recovery_action: BootstrapRecoveryAction,
185 pub last_error: Option<String>,
187 pub phase_history: Vec<NextjsBootstrapPhase>,
189}
190
191#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
193pub struct NextjsBootstrapConfig {
194 pub route_segment: String,
196 pub initial_environment: NextjsRenderEnvironment,
198 pub popstate_preserves_runtime: bool,
200}
201
202impl Default for NextjsBootstrapConfig {
203 fn default() -> Self {
204 Self {
205 route_segment: "/".to_string(),
206 initial_environment: NextjsRenderEnvironment::ClientSsr,
207 popstate_preserves_runtime: true,
208 }
209 }
210}
211
212#[derive(Debug, Clone, PartialEq, Eq, Error)]
214pub enum NextjsBootstrapError {
215 #[error(transparent)]
217 InvalidTransition(#[from] NextjsBootstrapTransitionError),
218 #[error(
220 "runtime initialization requires hydrated client environment; got {environment:?} in {phase:?}"
221 )]
222 RuntimeUnavailable {
223 environment: NextjsRenderEnvironment,
225 phase: NextjsBootstrapPhase,
227 },
228 #[error("command `{command}` is invalid in phase {phase:?}")]
230 InvalidCommand {
231 command: &'static str,
233 phase: NextjsBootstrapPhase,
235 },
236}
237
238#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
240pub struct NextjsBootstrapState {
241 config: NextjsBootstrapConfig,
242 snapshot: NextjsBootstrapSnapshot,
243}
244
245impl Default for NextjsBootstrapState {
246 fn default() -> Self {
247 Self::new()
248 }
249}
250
251impl NextjsBootstrapState {
252 #[must_use]
254 pub fn new() -> Self {
255 Self::with_config(NextjsBootstrapConfig::default())
256 }
257
258 #[must_use]
260 pub fn with_config(config: NextjsBootstrapConfig) -> Self {
261 let phase = NextjsBootstrapPhase::ServerRendered;
262 Self {
263 snapshot: NextjsBootstrapSnapshot {
264 phase,
265 environment: config.initial_environment,
266 route_segment: config.route_segment.clone(),
267 runtime_initialized: false,
268 runtime_init_attempts: 0,
269 runtime_init_successes: 0,
270 runtime_failure_count: 0,
271 cancellation_count: 0,
272 hydration_mismatch_count: 0,
273 soft_navigation_count: 0,
274 hard_navigation_count: 0,
275 popstate_navigation_count: 0,
276 cache_revalidation_count: 0,
277 scope_invalidation_count: 0,
278 runtime_reinit_required_count: 0,
279 active_scope_generation: 0,
280 last_invalidated_scope_generation: None,
281 hot_reload_count: 0,
282 last_recovery_action: BootstrapRecoveryAction::None,
283 last_error: None,
284 phase_history: vec![phase],
285 },
286 config,
287 }
288 }
289
290 #[must_use]
292 pub fn snapshot(&self) -> &NextjsBootstrapSnapshot {
293 &self.snapshot
294 }
295
296 pub fn apply(
298 &mut self,
299 command: BootstrapCommand,
300 ) -> Result<BootstrapLogEvent, NextjsBootstrapError> {
301 let action = command.name().to_string();
302 let from_phase = self.snapshot.phase;
303 let from_environment = self.snapshot.environment;
304 self.snapshot.last_recovery_action = BootstrapRecoveryAction::None;
305 self.snapshot.last_error = None;
306
307 let detail = match &command {
308 BootstrapCommand::RuntimeInitFailed { reason }
309 | BootstrapCommand::CancelBootstrap { reason }
310 | BootstrapCommand::HydrationMismatch { reason } => Some(reason.clone()),
311 BootstrapCommand::Navigate { nav, route_segment } => {
312 Some(format!("nav={nav:?}, route={route_segment}"))
313 }
314 BootstrapCommand::Recover { action } => Some(format!("recover={action:?}")),
315 _ => None,
316 };
317
318 self.handle_command(command)?;
319
320 Ok(BootstrapLogEvent {
321 action,
322 from_phase,
323 to_phase: self.snapshot.phase,
324 from_environment,
325 to_environment: self.snapshot.environment,
326 route_segment: self.snapshot.route_segment.clone(),
327 recovery_action: self.snapshot.last_recovery_action,
328 detail,
329 })
330 }
331
332 fn handle_command(&mut self, command: BootstrapCommand) -> Result<(), NextjsBootstrapError> {
333 match command {
334 BootstrapCommand::BeginHydration => self.handle_begin_hydration(),
335 BootstrapCommand::CompleteHydration => self.handle_complete_hydration(),
336 BootstrapCommand::InitializeRuntime => self.handle_initialize_runtime(),
337 BootstrapCommand::RuntimeInitFailed { reason } => {
338 self.handle_runtime_init_failed(reason)
339 }
340 BootstrapCommand::CancelBootstrap { reason } => {
341 self.handle_cancel_bootstrap(reason);
342 Ok(())
343 }
344 BootstrapCommand::HydrationMismatch { reason } => {
345 self.handle_hydration_mismatch(reason);
346 Ok(())
347 }
348 BootstrapCommand::Recover { action } => {
349 self.apply_recovery(action);
350 Ok(())
351 }
352 BootstrapCommand::Navigate { nav, route_segment } => {
353 self.handle_navigation(nav, route_segment);
354 Ok(())
355 }
356 BootstrapCommand::HotReload => {
357 self.handle_hot_reload();
358 Ok(())
359 }
360 BootstrapCommand::CacheRevalidated => self.handle_cache_revalidated(),
361 }
362 }
363
364 fn handle_begin_hydration(&mut self) -> Result<(), NextjsBootstrapError> {
365 self.transition_to(NextjsBootstrapPhase::Hydrating)
366 }
367
368 fn handle_complete_hydration(&mut self) -> Result<(), NextjsBootstrapError> {
369 self.transition_to(NextjsBootstrapPhase::Hydrated)?;
370 self.snapshot.environment = NextjsRenderEnvironment::ClientHydrated;
371 Ok(())
372 }
373
374 fn handle_initialize_runtime(&mut self) -> Result<(), NextjsBootstrapError> {
375 if self.snapshot.phase == NextjsBootstrapPhase::RuntimeReady {
376 return Ok(());
377 }
378
379 if !self.snapshot.environment.supports_wasm_runtime()
380 || self.snapshot.phase != NextjsBootstrapPhase::Hydrated
381 {
382 return Err(NextjsBootstrapError::RuntimeUnavailable {
383 environment: self.snapshot.environment,
384 phase: self.snapshot.phase,
385 });
386 }
387
388 self.snapshot.runtime_init_attempts = self.snapshot.runtime_init_attempts.saturating_add(1);
389 self.transition_to(NextjsBootstrapPhase::RuntimeReady)?;
390 self.snapshot.runtime_initialized = true;
391 self.snapshot.runtime_init_successes =
392 self.snapshot.runtime_init_successes.saturating_add(1);
393 self.snapshot.active_scope_generation =
394 self.snapshot.active_scope_generation.saturating_add(1);
395 Ok(())
396 }
397
398 fn handle_runtime_init_failed(&mut self, reason: String) -> Result<(), NextjsBootstrapError> {
399 if self.snapshot.phase != NextjsBootstrapPhase::Hydrated {
400 return Err(NextjsBootstrapError::InvalidCommand {
401 command: "runtime_init_failed",
402 phase: self.snapshot.phase,
403 });
404 }
405 self.transition_to(NextjsBootstrapPhase::RuntimeFailed)?;
406 self.snapshot.runtime_failure_count = self.snapshot.runtime_failure_count.saturating_add(1);
407 self.snapshot.last_error = Some(reason);
408 Ok(())
409 }
410
411 fn handle_cancel_bootstrap(&mut self, reason: String) {
412 self.snapshot.cancellation_count = self.snapshot.cancellation_count.saturating_add(1);
413 self.snapshot.runtime_failure_count = self.snapshot.runtime_failure_count.saturating_add(1);
414 self.force_transition(NextjsBootstrapPhase::RuntimeFailed);
415 self.snapshot.last_error = Some(reason);
416 }
417
418 fn handle_hydration_mismatch(&mut self, reason: String) {
419 self.snapshot.hydration_mismatch_count =
420 self.snapshot.hydration_mismatch_count.saturating_add(1);
421 self.snapshot.runtime_failure_count = self.snapshot.runtime_failure_count.saturating_add(1);
422 self.force_transition(NextjsBootstrapPhase::RuntimeFailed);
423 self.snapshot.last_error = Some(reason);
424 }
425
426 fn handle_navigation(&mut self, nav: NextjsNavigationType, route_segment: String) {
427 self.snapshot.route_segment = route_segment;
428 match nav {
429 NextjsNavigationType::SoftNavigation => {
430 self.snapshot.soft_navigation_count =
431 self.snapshot.soft_navigation_count.saturating_add(1);
432 }
433 NextjsNavigationType::HardNavigation => {
434 self.snapshot.hard_navigation_count =
435 self.snapshot.hard_navigation_count.saturating_add(1);
436 self.invalidate_runtime_scope("hard_navigation_scope_reset");
437 self.snapshot.environment = NextjsRenderEnvironment::ClientSsr;
438 self.force_transition(NextjsBootstrapPhase::ServerRendered);
439 }
440 NextjsNavigationType::PopState => {
441 self.snapshot.popstate_navigation_count =
442 self.snapshot.popstate_navigation_count.saturating_add(1);
443 if !(self.config.popstate_preserves_runtime
444 && self.snapshot.phase == NextjsBootstrapPhase::RuntimeReady)
445 {
446 self.invalidate_runtime_scope("popstate_scope_reset");
447 self.snapshot.environment = NextjsRenderEnvironment::ClientSsr;
448 self.force_transition(NextjsBootstrapPhase::ServerRendered);
449 }
450 }
451 }
452 }
453
454 fn handle_hot_reload(&mut self) {
455 self.snapshot.hot_reload_count = self.snapshot.hot_reload_count.saturating_add(1);
456 self.invalidate_runtime_scope("hot_reload_scope_reset");
457 self.snapshot.environment = NextjsRenderEnvironment::ClientSsr;
458 self.force_transition(NextjsBootstrapPhase::Hydrating);
459 }
460
461 fn handle_cache_revalidated(&mut self) -> Result<(), NextjsBootstrapError> {
462 if !matches!(
463 self.snapshot.phase,
464 NextjsBootstrapPhase::Hydrated | NextjsBootstrapPhase::RuntimeReady
465 ) {
466 return Err(NextjsBootstrapError::InvalidCommand {
467 command: "cache_revalidated",
468 phase: self.snapshot.phase,
469 });
470 }
471 self.snapshot.cache_revalidation_count =
472 self.snapshot.cache_revalidation_count.saturating_add(1);
473 if self.snapshot.phase == NextjsBootstrapPhase::RuntimeReady {
474 self.invalidate_runtime_scope("cache_revalidation_scope_reset");
475 self.snapshot.environment = NextjsRenderEnvironment::ClientHydrated;
476 self.force_transition(NextjsBootstrapPhase::Hydrated);
477 }
478 Ok(())
479 }
480
481 fn invalidate_runtime_scope(&mut self, reason: &str) {
482 if self.snapshot.runtime_initialized {
483 self.snapshot.scope_invalidation_count =
484 self.snapshot.scope_invalidation_count.saturating_add(1);
485 self.snapshot.runtime_reinit_required_count = self
486 .snapshot
487 .runtime_reinit_required_count
488 .saturating_add(1);
489 self.snapshot.cancellation_count = self.snapshot.cancellation_count.saturating_add(1);
490 self.snapshot.last_invalidated_scope_generation =
491 Some(self.snapshot.active_scope_generation);
492 self.snapshot.last_error = Some(reason.to_string());
493 }
494 self.snapshot.runtime_initialized = false;
495 }
496
497 fn transition_to(&mut self, to: NextjsBootstrapPhase) -> Result<(), NextjsBootstrapError> {
498 validate_bootstrap_transition(self.snapshot.phase, to)?;
499 self.force_transition(to);
500 Ok(())
501 }
502
503 fn force_transition(&mut self, to: NextjsBootstrapPhase) {
504 if self.snapshot.phase != to {
505 self.snapshot.phase = to;
506 self.snapshot.phase_history.push(to);
507 }
508 }
509
510 fn apply_recovery(&mut self, action: BootstrapRecoveryAction) {
511 match action {
512 BootstrapRecoveryAction::None => {}
513 BootstrapRecoveryAction::ResetToHydrating => {
514 self.snapshot.environment = NextjsRenderEnvironment::ClientSsr;
515 self.snapshot.runtime_initialized = false;
516 self.force_transition(NextjsBootstrapPhase::Hydrating);
517 }
518 BootstrapRecoveryAction::RetryRuntimeInit => {
519 self.snapshot.environment = NextjsRenderEnvironment::ClientHydrated;
520 self.force_transition(NextjsBootstrapPhase::Hydrated);
521 }
522 }
523 self.snapshot.last_recovery_action = action;
524 }
525}
526
527#[cfg(test)]
528mod tests {
529 use super::*;
530
531 #[test]
532 fn happy_path_server_render_to_runtime_ready() {
533 let mut state = NextjsBootstrapState::new();
534 state
535 .apply(BootstrapCommand::BeginHydration)
536 .expect("begin hydration");
537 state
538 .apply(BootstrapCommand::CompleteHydration)
539 .expect("complete hydration");
540 state
541 .apply(BootstrapCommand::InitializeRuntime)
542 .expect("init runtime");
543
544 let snapshot = state.snapshot();
545 assert_eq!(snapshot.phase, NextjsBootstrapPhase::RuntimeReady);
546 assert_eq!(
547 snapshot.environment,
548 NextjsRenderEnvironment::ClientHydrated
549 );
550 assert!(snapshot.runtime_initialized);
551 assert_eq!(snapshot.runtime_init_attempts, 1);
552 assert_eq!(snapshot.runtime_init_successes, 1);
553 }
554
555 #[test]
556 fn runtime_init_is_idempotent_for_double_invoke_paths() {
557 let mut state = NextjsBootstrapState::new();
558 state
559 .apply(BootstrapCommand::BeginHydration)
560 .expect("begin hydration");
561 state
562 .apply(BootstrapCommand::CompleteHydration)
563 .expect("complete hydration");
564 state
565 .apply(BootstrapCommand::InitializeRuntime)
566 .expect("first init");
567 state
568 .apply(BootstrapCommand::InitializeRuntime)
569 .expect("idempotent second init");
570
571 let snapshot = state.snapshot();
572 assert_eq!(snapshot.runtime_init_attempts, 1);
573 assert_eq!(snapshot.runtime_init_successes, 1);
574 }
575
576 #[test]
577 fn cancellation_and_recovery_path_is_supported() {
578 let mut state = NextjsBootstrapState::new();
579 state
580 .apply(BootstrapCommand::BeginHydration)
581 .expect("begin hydration");
582 state
583 .apply(BootstrapCommand::CompleteHydration)
584 .expect("complete hydration");
585 state
586 .apply(BootstrapCommand::CancelBootstrap {
587 reason: "navigation interrupt".to_string(),
588 })
589 .expect("cancel");
590 state
591 .apply(BootstrapCommand::Recover {
592 action: BootstrapRecoveryAction::RetryRuntimeInit,
593 })
594 .expect("recover");
595 state
596 .apply(BootstrapCommand::InitializeRuntime)
597 .expect("init after recovery");
598
599 let snapshot = state.snapshot();
600 assert_eq!(snapshot.phase, NextjsBootstrapPhase::RuntimeReady);
601 assert_eq!(snapshot.cancellation_count, 1);
602 assert_eq!(snapshot.runtime_failure_count, 1);
603 }
604
605 #[test]
606 fn log_fields_include_required_bootstrap_dimensions() {
607 let mut state = NextjsBootstrapState::new();
608 let event = state
609 .apply(BootstrapCommand::BeginHydration)
610 .expect("begin hydration");
611 let fields = event.as_log_fields();
612
613 assert!(fields.contains_key("action"));
614 assert!(fields.contains_key("from_phase"));
615 assert!(fields.contains_key("to_phase"));
616 assert!(fields.contains_key("from_environment"));
617 assert!(fields.contains_key("to_environment"));
618 assert!(fields.contains_key("route_segment"));
619 assert!(fields.contains_key("recovery_action"));
620 }
621
622 #[test]
623 fn cache_revalidation_invalidates_runtime_scope_and_requires_reinit() {
624 let mut state = NextjsBootstrapState::new();
625 state
626 .apply(BootstrapCommand::BeginHydration)
627 .expect("begin hydration");
628 state
629 .apply(BootstrapCommand::CompleteHydration)
630 .expect("complete hydration");
631 state
632 .apply(BootstrapCommand::InitializeRuntime)
633 .expect("init runtime");
634 assert_eq!(state.snapshot().active_scope_generation, 1);
635
636 state
637 .apply(BootstrapCommand::CacheRevalidated)
638 .expect("cache revalidated");
639
640 let snapshot = state.snapshot();
641 assert_eq!(snapshot.phase, NextjsBootstrapPhase::Hydrated);
642 assert!(!snapshot.runtime_initialized);
643 assert_eq!(snapshot.cache_revalidation_count, 1);
644 assert_eq!(snapshot.scope_invalidation_count, 1);
645 assert_eq!(snapshot.runtime_reinit_required_count, 1);
646 assert_eq!(snapshot.cancellation_count, 1);
647 assert_eq!(snapshot.last_invalidated_scope_generation, Some(1));
648
649 state
650 .apply(BootstrapCommand::InitializeRuntime)
651 .expect("re-init runtime");
652 let snapshot = state.snapshot();
653 assert_eq!(snapshot.phase, NextjsBootstrapPhase::RuntimeReady);
654 assert_eq!(snapshot.active_scope_generation, 2);
655 assert_eq!(snapshot.runtime_init_attempts, 2);
656 assert_eq!(snapshot.runtime_init_successes, 2);
657 }
658
659 #[test]
660 fn cache_revalidation_while_hydrated_without_runtime_does_not_invalidate_scope() {
661 let mut state = NextjsBootstrapState::new();
662 state
663 .apply(BootstrapCommand::BeginHydration)
664 .expect("begin hydration");
665 state
666 .apply(BootstrapCommand::CompleteHydration)
667 .expect("complete hydration");
668
669 state
670 .apply(BootstrapCommand::CacheRevalidated)
671 .expect("cache revalidated");
672
673 let snapshot = state.snapshot();
674 assert_eq!(snapshot.phase, NextjsBootstrapPhase::Hydrated);
675 assert!(!snapshot.runtime_initialized);
676 assert_eq!(snapshot.cache_revalidation_count, 1);
677 assert_eq!(snapshot.scope_invalidation_count, 0);
678 assert_eq!(snapshot.runtime_reinit_required_count, 0);
679 assert_eq!(snapshot.cancellation_count, 0);
680 assert_eq!(snapshot.last_invalidated_scope_generation, None);
681 }
682
683 #[test]
684 fn hard_navigation_invalidates_runtime_scope_before_reset() {
685 let mut state = NextjsBootstrapState::new();
686 state
687 .apply(BootstrapCommand::BeginHydration)
688 .expect("begin hydration");
689 state
690 .apply(BootstrapCommand::CompleteHydration)
691 .expect("complete hydration");
692 state
693 .apply(BootstrapCommand::InitializeRuntime)
694 .expect("init runtime");
695 assert_eq!(state.snapshot().active_scope_generation, 1);
696
697 state
698 .apply(BootstrapCommand::Navigate {
699 nav: NextjsNavigationType::HardNavigation,
700 route_segment: "/settings".to_string(),
701 })
702 .expect("hard navigation");
703
704 let snapshot = state.snapshot();
705 assert_eq!(snapshot.phase, NextjsBootstrapPhase::ServerRendered);
706 assert!(!snapshot.runtime_initialized);
707 assert_eq!(snapshot.scope_invalidation_count, 1);
708 assert_eq!(snapshot.runtime_reinit_required_count, 1);
709 assert_eq!(snapshot.cancellation_count, 1);
710 assert_eq!(snapshot.last_invalidated_scope_generation, Some(1));
711 }
712}