"""Standalone zone rendering over the full page context."""
import time
from dataclasses import dataclass
from typing import TYPE_CHECKING
from django.template import Context as DjangoTemplateContext
from next.pages.manager import page
from next.static.collector import default_placeholders
from next.static.manager import default_manager
from .registry import zones_of
from .signals import zone_rendered
from .zone import render_zone_body
if TYPE_CHECKING:
from collections.abc import Iterator, Mapping
from pathlib import Path
from django.http import HttpRequest
from next.static import StaticCollector
from .registry import ZoneInfo
[docs]
class UnknownZoneError(LookupError):
"""Raised when a partial request names a zone the page does not declare.
The unified view turns this into a 400 before any zone renders, so a
typo or a stale client never trips a partial render. The message names
the declared zones so a builder-path typo points at what is available.
"""
[docs]
def __init__(self, zone_name: str, declared: tuple[str, ...] = ()) -> None:
"""Store the unknown zone name and the declared zone names available."""
self.zone_name = zone_name
self.declared = declared
if declared:
names = ", ".join(repr(name) for name in declared)
message = f'Unknown zone "{zone_name}". Declared zones: {names}.'
else:
message = f'Unknown zone "{zone_name}".'
super().__init__(message)
[docs]
@dataclass(frozen=True, slots=True)
class ZoneRenderResult:
"""Rendered zones plus the assets their bodies collected.
`html` maps each rendered zone name to its wrapped marker element and
`bodies` maps it to the bare inner body. A morph or replace addresses
the wrapped element, an append or prepend grafts the bare body into the
live zone. `collector` carries the co-located assets the bodies
registered so the caller can ship a manifest outward, past the no-op
inject.
"""
html: dict[str, str]
bodies: dict[str, str]
collector: "StaticCollector"
[docs]
def url_assets(self) -> "Iterator[tuple[str, str]]":
"""Yield the (kind, url) pair of each collected asset that has a URL."""
for slot in default_placeholders:
for static_asset in self.collector.assets_in_slot(slot.name):
if static_asset.url:
yield static_asset.kind, static_asset.url
[docs]
def inline_assets(self) -> "Iterator[tuple[str, str]]":
"""Yield the (kind, inline body) of each collected inline asset."""
for slot in default_placeholders:
for static_asset in self.collector.assets_in_slot(slot.name):
if static_asset.inline is not None:
yield static_asset.kind, static_asset.inline
[docs]
def js_context_delta(self) -> dict[str, object]:
"""Return the zone js-context as wire-ready values for a context patch."""
return self.collector.js_context_wire()
[docs]
def render_zone(
page_path: "Path",
zone_names: tuple[str, ...],
request: "HttpRequest",
url_kwargs: dict[str, object] | None = None,
overrides: dict[str, object] | None = None,
*,
context_data: dict[str, object] | None = None,
) -> ZoneRenderResult:
"""Render the named zones of a page with the full page context.
The context is collected once for the whole batch of names through
`build_render_context` and a fresh collector is seeded by the same
convention as the canonical render path, so co-located assets of the
zone bodies are gathered. A caller that already built the origin context
passes it as `context_data` so it is reused rather than rebuilt. The
manifest travels outward in the result rather than through inject, which
is a no-op for fragments. Unknown zone names are skipped so one stale
name never poisons a batch, but a batch of only unknown names raises so
a single-zone request keeps its 400.
"""
start = time.perf_counter()
kwargs = url_kwargs or {}
template = page.composed_template_for(page_path)
zones = zones_of(template)
rendered_names = _renderable_zone_names(zone_names, zones)
if context_data is None:
context_data = page.build_render_context(page_path, request, **kwargs)
if overrides:
context_data.update(overrides)
collector = _seed_collector(page_path, context_data)
django_context = DjangoTemplateContext(context_data)
html: dict[str, str] = {}
bodies: dict[str, str] = {}
for name in rendered_names:
info = zones[name]
body, wrapped = render_zone_body(
info.partial, info.name, info.options, django_context
)
# SafeString is a str, kept as-is to avoid copies on the hot path.
html[name] = wrapped
bodies[name] = body
_emit_rendered(page_path, rendered_names, request, start)
return ZoneRenderResult(html=html, bodies=bodies, collector=collector)
def _renderable_zone_names(
zone_names: tuple[str, ...], zones: "Mapping[str, ZoneInfo]"
) -> tuple[str, ...]:
"""Return the declared names of a batch, deduplicated in request order.
A name the page does not declare is dropped so one stale name never
poisons the batch. A non-empty batch left with no declared name raises,
naming the first unknown, so a single-zone request keeps its 400. An
empty batch stays the no-op it always was.
"""
rendered = tuple(name for name in dict.fromkeys(zone_names) if name in zones)
if zone_names and not rendered:
raise UnknownZoneError(zone_names[0], tuple(sorted(zones)))
return rendered
def _seed_collector(
page_path: "Path", context_data: dict[str, object]
) -> "StaticCollector":
"""Seed a fresh collector and bind it to the context like the page path.
The collector is hydrated with the JS context that
`build_render_context` left behind, page asset discovery runs, and
the collector is bound under `_static_collector` so component widgets
and co-located assets of the zone bodies register against it.
"""
collector: StaticCollector = default_manager.create_collector()
js_context = context_data.pop("_next_js_context", {})
js_serializers = context_data.pop("_next_js_context_serializers", {})
if isinstance(js_context, dict):
serializers = js_serializers if isinstance(js_serializers, dict) else {}
for js_key, js_value in js_context.items():
collector.add_js_context(
js_key, js_value, serializer=serializers.get(js_key)
)
default_manager.discover_page_assets(page_path, collector)
context_data["_static_collector"] = collector
return collector
def _emit_rendered(
page_path: "Path", zone_names: tuple[str, ...], request: "HttpRequest", start: float
) -> None:
"""Announce each rendered zone when the signal has receivers."""
if not zone_rendered.receivers:
return
duration_ms = (time.perf_counter() - start) * 1000
for name in zone_names:
zone_rendered.send(
sender=ZoneRenderResult,
zone_name=name,
page_path=page_path,
request=request,
duration_ms=duration_ms,
)
__all__ = ["UnknownZoneError", "ZoneRenderResult", "render_zone"]