Skip to main content

asupersync/web/
nextjs_bootstrap.rs

1//! Hydration-safe Next.js client bootstrap state machine.
2//!
3//! This module models the runtime bootstrap protocol used by client boundaries
4//! in a Next.js-style application:
5//! 1. `ServerRendered -> Hydrating -> Hydrated`
6//! 2. Runtime initialization only after hydration
7//! 3. Deterministic recovery for failures (mismatch/cancel/hot-reload)
8
9use 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/// Recovery action taken after a bootstrap failure signal.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum BootstrapRecoveryAction {
21    /// No recovery action was needed.
22    None,
23    /// Reset to hydration and re-run client bootstrap.
24    ResetToHydrating,
25    /// Keep hydrated state and retry runtime initialization.
26    RetryRuntimeInit,
27}
28
29/// Command applied to the bootstrap state machine.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum BootstrapCommand {
33    /// Begin hydration pass.
34    BeginHydration,
35    /// Mark hydration complete.
36    CompleteHydration,
37    /// Initialize runtime once hydration is complete.
38    InitializeRuntime,
39    /// Runtime initialization failed with a diagnostic.
40    RuntimeInitFailed {
41        /// Failure detail.
42        reason: String,
43    },
44    /// Bootstrap was interrupted by cancellation.
45    CancelBootstrap {
46        /// Cancellation detail.
47        reason: String,
48    },
49    /// Hydration mismatch detected.
50    HydrationMismatch {
51        /// Mismatch detail.
52        reason: String,
53    },
54    /// Apply explicit recovery to continue bootstrap.
55    Recover {
56        /// Recovery action to apply.
57        action: BootstrapRecoveryAction,
58    },
59    /// Apply route navigation.
60    Navigate {
61        /// Navigation type.
62        nav: NextjsNavigationType,
63        /// New route segment.
64        route_segment: String,
65    },
66    /// Apply a hot-reload remount cycle.
67    HotReload,
68    /// Apply cache revalidation while hydrated/runtime-ready.
69    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/// Deterministic structured event emitted after one command.
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub struct BootstrapLogEvent {
92    /// Command name.
93    pub action: String,
94    /// Phase before command.
95    pub from_phase: NextjsBootstrapPhase,
96    /// Phase after command.
97    pub to_phase: NextjsBootstrapPhase,
98    /// Environment before command.
99    pub from_environment: NextjsRenderEnvironment,
100    /// Environment after command.
101    pub to_environment: NextjsRenderEnvironment,
102    /// Active route after command.
103    pub route_segment: String,
104    /// Recovery action taken, if any.
105    pub recovery_action: BootstrapRecoveryAction,
106    /// Optional diagnostic detail.
107    pub detail: Option<String>,
108}
109
110impl BootstrapLogEvent {
111    /// Deterministic key-sorted fields for logging pipelines.
112    #[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/// Bootstrap state snapshot for diagnostics.
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140pub struct NextjsBootstrapSnapshot {
141    /// Current bootstrap phase.
142    pub phase: NextjsBootstrapPhase,
143    /// Current render environment.
144    pub environment: NextjsRenderEnvironment,
145    /// Current route segment.
146    pub route_segment: String,
147    /// Whether runtime init succeeded at least once in this lifecycle.
148    pub runtime_initialized: bool,
149    /// Number of runtime initialization attempts.
150    pub runtime_init_attempts: u32,
151    /// Number of successful runtime initialization calls.
152    pub runtime_init_successes: u32,
153    /// Number of runtime failures observed.
154    pub runtime_failure_count: u32,
155    /// Number of runtime cancellations observed.
156    ///
157    /// Includes explicit bootstrap cancellations (`CancelBootstrap`) and
158    /// deterministic scope invalidations (cache revalidation, hard navigation,
159    /// hot reload) that require draining active runtime work.
160    pub cancellation_count: u32,
161    /// Number of hydration mismatches observed.
162    pub hydration_mismatch_count: u32,
163    /// Number of soft navigations observed.
164    pub soft_navigation_count: u32,
165    /// Number of hard navigations observed.
166    pub hard_navigation_count: u32,
167    /// Number of popstate navigations observed.
168    pub popstate_navigation_count: u32,
169    /// Number of cache revalidation events observed.
170    pub cache_revalidation_count: u32,
171    /// Number of runtime scope invalidations triggered by route/cache events.
172    pub scope_invalidation_count: u32,
173    /// Number of times invalidation required runtime re-initialization.
174    pub runtime_reinit_required_count: u32,
175    /// Current active runtime scope generation.
176    ///
177    /// Increments on each successful runtime initialization.
178    pub active_scope_generation: u32,
179    /// Last invalidated runtime scope generation, if any.
180    pub last_invalidated_scope_generation: Option<u32>,
181    /// Number of hot reload events observed.
182    pub hot_reload_count: u32,
183    /// Last recovery action taken.
184    pub last_recovery_action: BootstrapRecoveryAction,
185    /// Last error detail.
186    pub last_error: Option<String>,
187    /// Phase history for deterministic replay.
188    pub phase_history: Vec<NextjsBootstrapPhase>,
189}
190
191/// Configuration for bootstrap behavior.
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
193pub struct NextjsBootstrapConfig {
194    /// Initial route segment.
195    pub route_segment: String,
196    /// Initial render environment.
197    pub initial_environment: NextjsRenderEnvironment,
198    /// Whether popstate should preserve runtime when already ready.
199    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/// Error raised by bootstrap state transitions.
213#[derive(Debug, Clone, PartialEq, Eq, Error)]
214pub enum NextjsBootstrapError {
215    /// Core phase transition was invalid.
216    #[error(transparent)]
217    InvalidTransition(#[from] NextjsBootstrapTransitionError),
218    /// Runtime initialization was requested outside hydrated client context.
219    #[error(
220        "runtime initialization requires hydrated client environment; got {environment:?} in {phase:?}"
221    )]
222    RuntimeUnavailable {
223        /// Current environment.
224        environment: NextjsRenderEnvironment,
225        /// Current phase.
226        phase: NextjsBootstrapPhase,
227    },
228    /// Command cannot execute in current phase.
229    #[error("command `{command}` is invalid in phase {phase:?}")]
230    InvalidCommand {
231        /// Command name.
232        command: &'static str,
233        /// Current phase.
234        phase: NextjsBootstrapPhase,
235    },
236}
237
238/// Deterministic bootstrap state machine for Next.js client boundaries.
239#[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    /// Construct state with default config.
253    #[must_use]
254    pub fn new() -> Self {
255        Self::with_config(NextjsBootstrapConfig::default())
256    }
257
258    /// Construct state with explicit config.
259    #[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    /// Return current immutable snapshot.
291    #[must_use]
292    pub fn snapshot(&self) -> &NextjsBootstrapSnapshot {
293        &self.snapshot
294    }
295
296    /// Apply one command and return a deterministic log event.
297    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}