Repository: EmilStenstrom/django-components

Issue:
RequestContext not populated with context processors on async request
Component's rendered via an HTTP request that use `RequestContext` are not populated with values defined in `context_processors`

---

As per [Django's `RequestContext` docs](https://docs.djangoproject.com/en/5.1/ref/templates/api/#django.template.RequestContext), `RequestContext` differs from `Context` in that it makes the configured `context_processors` available when rendering templates

The context processors are added to context when `RequestContext.bind_template` is called, and things like `csrf_token` are then made available in templates

Since at least `django-components@0.95` (potentially since the introduction of the template monkey-patching) the context processors are not added to the context because [_monkeypatch_template._template_render](https://github.com/EmilStenstrom/django-components/blob/0cfc40231bed770cbe00256b2da0b9a9d2f37828/src/django_components/component.py#L838) doesn't call [`context.bind_template` for `RequestContext`](https://github.com/django/django/blob/64443f555f362ddf0c9094f8531c3da72a753827/django/template/context.py#L247) when the component is rendered via a network request. Because the component is not rendered as the child of a larger context, as when rendering a full page, there is no outer context available to the component, as when rendered during a 'normal' full-page request

## Demo

- https://replit.com/@larrybotha1/Django-Components-088#django_project/components/thing/index.html

## Steps to reproduce

- ensure `'django.contrib.auth.context_processors.auth'` is included in context processors in settings
- create a component that is rendered via a network request
- pass `context=RequestContext(request)` to `render_to_response`
- make a request for the component, rendering `{{ csrf_token }}`

## Expected

- `{{ csrf_token }}` is rendered when the component is rendered via a network request

## Actual

- `{{ csrf_token }}` is not available

## Possible solutions

I haven't yet worked out how this could be solved. The difference here is in what `Context.bind_template` and `RequestContext.bind_template` do. `Context` does little except yield the template, whereas `RequestContext` augments the context.

`django-components` does the correct thing in that it evaluates whether `template is None` before calling `bind_template`, so the problem may stem from [where `Component.template` is assigned](https://github.com/EmilStenstrom/django-components/blob/0cfc40231bed770cbe00256b2da0b9a9d2f37828/src/django_components/component.py#L524) - should `template` never be assigned directly, and only via `bind_template`?

## Workarounds

- instantiate `RequestContext` with the context processors manually

EDIT: improved clarity of description of problem

Discussion:
@JuroOravec: @larrybotha Thanks for the report. Looks like this issue is probably present since https://github.com/EmilStenstrom/django-components/releases/tag/0.89 and https://github.com/EmilStenstrom/django-components/pull/582.

Not sure if I remember correctly, but I think I didn't use `bind_template` because it was raising some error about template or engine not being present or something...

I still don't fully grasp the workings of Context / RenderContext, but I wonder, in `Template.render`, Django has this:

```py
def render(self, context):
    "Display stage -- can be called many times"
    with context.render_context.push_state(self):
        if context.template is None:
            with context.bind_template(self):
                context.template_name = self.name
                return self._render(context)
        else:
            return self._render(context)
```

So my guess would be to:
- We could try using `context.bind_template`, see what breaks, and make changes so we can call `bind_templates` properly
- Maybe we need to call also `with context.render_context.push_state(self):` for the context to be populated?@JuroOravec: Ok, @larrybotha I got the demo repo working by wrapping the rest of the `_render_impl()` body in `with context.bind_template(template):` if `context.template` is `None`.

<img width="572" alt="Screenshot 2024-09-02 at 18 23 26" src="https://github.com/user-attachments/assets/a04dea2f-9195-44a5-bb90-a8606c282180">

Later today or tomorrow I'll make a test case to cover this, and then it's good to go!@larrybotha: @JuroOravec you're a machine

Ye, I ended up wrapping calls to `render_to_response` inside the context manager so that the template would bind before getting to `_render_impl`, so sounds about right

Thanks for taking the time from other issues and work to look into this!@JuroOravec: @larrybotha This has now been released in [0.96](https://github.com/EmilStenstrom/django-components/releases/tag/0.96) :)

Solution patch:
diff --git a/src/django_components/component.py b/src/django_components/component.py
index ef4e636e4..3edea4218 100644
--- a/src/django_components/component.py
+++ b/src/django_components/component.py
@@ -1,6 +1,7 @@
 import inspect
 import types
 from collections import deque
+from contextlib import contextmanager
 from dataclasses import dataclass
 from typing import (
     Any,
@@ -8,6 +9,7 @@
     ClassVar,
     Deque,
     Dict,
+    Generator,
     Generic,
     List,
     Literal,
@@ -520,22 +522,7 @@ def _render_impl(
         context_data = self.get_context_data(*args, **kwargs)
         self._validate_outputs(context_data)
 
-        with context.update(context_data):
-            template = self.get_template(context)
-            _monkeypatch_template(template)
-
-            if context.template is None:
-                # Associate the newly-created Context with a Template, otherwise we get
-                # an error when we try to use `{% include %}` tag inside the template?
-                # See https://github.com/EmilStenstrom/django-components/issues/580
-                context.template = template
-                context.template_name = template.name
-
-            # Set `Template._dc_is_component_nested` based on whether we're currently INSIDE
-            # the `{% extends %}` tag.
-            # Part of fix for https://github.com/EmilStenstrom/django-components/issues/508
-            template._dc_is_component_nested = bool(context.render_context.get(BLOCK_CONTEXT_KEY))
-
+        with _prepare_template(self, context, context_data) as template:
             # Support passing slots explicitly to `render` method
             if has_slots:
                 fill_content = self._fills_from_slots_data(
@@ -843,3 +830,35 @@ def _template_render(self: Template, context: Context, *args: Any, **kwargs: Any
 
     # See https://stackoverflow.com/a/42154067/9788634
     template.render = types.MethodType(_template_render, template)
+
+
+@contextmanager
+def _maybe_bind_template(context: Context, template: Template) -> Generator[None, Any, None]:
+    if context.template is None:
+        with context.bind_template(template):
+            yield
+    else:
+        yield
+
+
+@contextmanager
+def _prepare_template(
+    component: Component,
+    context: Context,
+    context_data: Any,
+) -> Generator[Template, Any, None]:
+    with context.update(context_data):
+        # Associate the newly-created Context with a Template, otherwise we get
+        # an error when we try to use `{% include %}` tag inside the template?
+        # See https://github.com/EmilStenstrom/django-components/issues/580
+        # And https://github.com/EmilStenstrom/django-components/issues/634
+        template = component.get_template(context)
+        _monkeypatch_template(template)
+
+        # Set `Template._dc_is_component_nested` based on whether we're currently INSIDE
+        # the `{% extends %}` tag.
+        # Part of fix for https://github.com/EmilStenstrom/django-components/issues/508
+        template._dc_is_component_nested = bool(context.render_context.get(BLOCK_CONTEXT_KEY))
+
+        with _maybe_bind_template(context, template):
+            yield template


Test patch:
diff --git a/tests/test_component.py b/tests/test_component.py
index df127f6d3..f09726c8d 100644
--- a/tests/test_component.py
+++ b/tests/test_component.py
@@ -3,6 +3,7 @@
 For tests focusing on the `component` tag, see `test_templatetags_component.py`
 """
 
+import re
 import sys
 from typing import Any, Dict, Tuple, Union, no_type_check
 
@@ -14,13 +15,16 @@
 
 from unittest import skipIf
 
+from django.conf import settings
 from django.core.exceptions import ImproperlyConfigured
 from django.http import HttpRequest, HttpResponse
 from django.template import Context, RequestContext, Template, TemplateSyntaxError
 from django.template.base import TextNode
+from django.test import Client
+from django.urls import path
 from django.utils.safestring import SafeString
 
-from django_components import Component, SlotFunc, registry, types
+from django_components import Component, ComponentView, SlotFunc, register, registry, types
 from django_components.slots import SlotRef
 
 from .django_test_setup import setup_test_config
@@ -29,6 +33,21 @@
 setup_test_config({"autodiscover": False})
 
 
+# Client for testing endpoints via requests
+class CustomClient(Client):
+    def __init__(self, urlpatterns=None, *args, **kwargs):
+        import types
+
+        if urlpatterns:
+            urls_module = types.ModuleType("urls")
+            urls_module.urlpatterns = urlpatterns  # type: ignore
+            settings.ROOT_URLCONF = urls_module
+        else:
+            settings.ROOT_URLCONF = __name__
+        settings.SECRET_KEY = "secret"  # noqa
+        super().__init__(*args, **kwargs)
+
+
 # Component typings
 CompArgs = Tuple[int, str]
 
@@ -772,6 +791,7 @@ class SimpleComponent(Component):
         )
 
     # See https://github.com/EmilStenstrom/django-components/issues/580
+    # And https://github.com/EmilStenstrom/django-components/issues/634
     # And https://github.com/EmilStenstrom/django-components/commit/fee26ec1d8b46b5ee065ca1ce6143889b0f96764
     @parametrize_context_behavior(["django", "isolated"])
     def test_render_with_include_and_request_context(self):
@@ -793,6 +813,63 @@ class SimpleComponent(Component):
             """,
         )
 
+    # See https://github.com/EmilStenstrom/django-components/issues/580
+    # And https://github.com/EmilStenstrom/django-components/issues/634
+    @parametrize_context_behavior(["django", "isolated"])
+    def test_request_context_is_populated_from_context_processors(self):
+        @register("thing")
+        class Thing(Component):
+            template: types.django_html = """
+                <kbd>Rendered {{ how }}</kbd>
+                <div>
+                    CSRF token: {{ csrf_token|default:"<em>No CSRF token</em>" }}
+                </div>
+            """
+
+            def get_context_data(self, *args, how: str, **kwargs):
+                return {"how": how}
+
+            class View(ComponentView):
+                def get(self, request):
+                    how = "via GET request"
+
+                    return self.component.render_to_response(
+                        context=RequestContext(self.request),
+                        kwargs=self.component.get_context_data(how=how),
+                    )
+
+        client = CustomClient(urlpatterns=[path("test_thing/", Thing.as_view())])
+        response = client.get("/test_thing/")
+
+        self.assertEqual(response.status_code, 200)
+
+        # Full response:
+        # """
+        # <kbd>
+        #     Rendered via GET request
+        # </kbd>
+        # <div>
+        #     CSRF token:
+        #     <div>
+        #         test_csrf_token
+        #     </div>
+        # </div>
+        # """
+        self.assertInHTML(
+            """
+            <kbd>
+                Rendered via GET request
+            </kbd>
+            """,
+            response.content.decode(),
+        )
+
+        token_re = re.compile(rb"CSRF token:\s+(?P<token>[0-9a-zA-Z]{64})")
+        token = token_re.findall(response.content)[0]
+
+        self.assertTrue(token)
+        self.assertEqual(len(token), 64)
+
     @parametrize_context_behavior(["django", "isolated"])
     def test_render_with_extends(self):
         class SimpleComponent(Component):