Source code for next.server.autoreload

"""Custom `StatReloader` that also watches the discovered route set.

`NextStatReloader` subclasses Django's `StatReloader` and adds a route
set comparison between ticks. When pages appear or disappear from the
routing tree, the reloader notifies Django even if no mtime changed.
`.djx` templates are deliberately not watched. They are re-read on
render with mtime-based invalidation inside pages and components.
"""

from __future__ import annotations

import logging
import os
from typing import TYPE_CHECKING

from django.utils.autoreload import StatReloader

from next.pages.watch import get_pages_directories_for_watch
from next.urls.dispatcher import scan_pages_tree


if TYPE_CHECKING:
    from collections.abc import Generator
    from pathlib import Path


logger = logging.getLogger(__name__)


def _tree_dir_signature(root: Path) -> tuple[float, int]:
    """Return `(max mtime, directory count)` across every subdirectory.

    Walks directories with `os.scandir` and uses each `DirEntry`'s
    cached stat, avoiding a second `Path.stat()` syscall per node.
    The entry count guards against two independent renames that happen
    to preserve the latest mtime.
    """
    try:
        root_st = root.stat()
    except OSError:
        return (0.0, 0)
    latest = root_st.st_mtime
    count = 1
    stack: list[str] = [str(root)]
    while stack:
        current = stack.pop()
        try:
            scanner = os.scandir(current)
        except OSError:
            continue
        with scanner as it:
            for entry in it:
                try:
                    if not entry.is_dir(follow_symlinks=False):
                        continue
                    st = entry.stat(follow_symlinks=False)
                except OSError:
                    continue
                latest = max(latest, st.st_mtime)
                count += 1
                stack.append(entry.path)
    return (latest, count)


[docs] class NextStatReloader(StatReloader): """Reload on route set changes in addition to `.py` mtime changes."""
[docs] def __init__(self) -> None: """Initialise the cached route set used for tick-to-tick diffs.""" super().__init__() self._previous_routes: set[tuple[str, Path]] | None = None self._dir_signatures: dict[Path, tuple[float, int]] = {} self._cached_routes: set[tuple[str, Path]] | None = None
def _check_routes(self, current: set[tuple[str, Path]]) -> None: """Notify the reloader when the discovered route set changed.""" prev = self._previous_routes if prev is None or current == prev: self._previous_routes = current return diff = (current - prev) or (prev - current) if diff: self.notify_file_changed(next(iter(diff))[1]) self._previous_routes = current def _collect_routes(self) -> set[tuple[str, Path]]: """Return the route set, reusing the cached value when signatures match.""" pages_paths = get_pages_directories_for_watch() new_signatures = {p: _tree_dir_signature(p) for p in pages_paths} if self._cached_routes is not None and new_signatures == self._dir_signatures: return self._cached_routes routes = { (url_path, file_path.resolve()) for pages_path in pages_paths for url_path, file_path in scan_pages_tree(pages_path) } self._dir_signatures = new_signatures self._cached_routes = routes return routes
[docs] def tick(self) -> Generator[None, None, None]: """Recompute routes, compare to the previous tick, then delegate.""" parent_ticker = super().tick() while True: try: routes = self._collect_routes() except (OSError, ImportError, ValueError) as e: logger.debug("next route set check skipped: %s", e) else: self._check_routes(routes) yield next(parent_ticker)