Repository: EmilStenstrom/django-components

Issue:
Template caching isn't working
Turns out that the `TEMPLATE_CACHE_SIZE` setting isn't being used ever since https://github.com/EmilStenstrom/django-components/commit/898d1483820778a05d7db9730b00d1702d241e6b (March 2023). We should put it back and test it.

Discussion:
@JuroOravec: Ok, continuing from https://github.com/EmilStenstrom/django-components/issues/613, there's 3 things on my mind:
1. What should be cached? I assume that only the inlined template (from `Component.template` or `Component.get_template_string`) needs to be cached, because templates loaded by the Django loader should be cached by Django ([see stackoverflow](https://stackoverflow.com/a/69677619/9788634))

2. To allow use cases like #613, I'd be great if the caching wasn't bound to a method, like it was [before](https://github.com/EmilStenstrom/django-components/commit/898d1483820778a05d7db9730b00d1702d241e6b#diff-569e56c0c056d2ede4e534ad3cae3717ff8e61ba7fdd6963a23df1adc8fd86d1L108), but could be used also independently, e.g.:
	```py
	from django_components import cached_template

	template = cached_template(
	    """
			<div>
				...
			</div>
		"""
	)
	```

	And we'd then call `cached_template` from within `Component.get_template()`.

3. While I'm at it, I also want to update the template-related Component API:

	Currently there's 5 attributes:
		- `template` - inlined string
		- `template_name` - template name string
		- `get_template_name` - function for template name string
		- `get_template_string` - function for inlined template string
		- `get_template` - Processes the above and returns an instance of `Template`

	To clean up the interface, I suggest to:
		- Merge `template` and `get_template_string` into `template` - And allow it to be string, a Template, or a function that returns one of.
		- Merge `template_name` and `get_template_name` into `template_name` - And allow it to be string, or a function that returns a string.
		- Make `get_template` private by renaming it to `_get_template` - After all, it isn't mentioned anywhere.
		- For compatibility `get_template_string` and `get_template_name` would still work until v1

Solution patch:
diff --git a/README.md b/README.md
index 64932179d..354eeed3e 100644
--- a/README.md
+++ b/README.md
@@ -71,6 +71,13 @@ And this is what gets rendered (plus the CSS and Javascript you've specified):
 
 ## Release notes
 
+**Version 0.97**
+- Fixed template caching. You can now also manually create cached templates with [`cached_template()`](#template_cache_size---tune-the-template-cache)
+- The previously undocumented `get_template` was made private.
+- In it's place, there's a new `get_template`, which supersedes `get_template_string` (will be removed in v1). The new `get_template` is the same as `get_template_string`, except
+it allows to return either a string or a Template instance.
+- You now must use only one of `template`, `get_template`, `template_name`, or `get_template_name`.
+
 **Version 0.96**
 - Run-time type validation for Python 3.11+ - If the `Component` class is typed, e.g. `Component[Args, Kwargs, ...]`, the args, kwargs, slots, and data are validated against the given types. (See [Runtime input validation with types](#runtime-input-validation-with-types))
 - Render hooks - Set `on_render_before` and `on_render_after` methods on `Component` to intercept or modify the template or context before rendering, or the rendered result afterwards. (See [Component hooks](#component-hooks))
@@ -93,7 +100,7 @@ And this is what gets rendered (plus the CSS and Javascript you've specified):
 🚨📢 **Version 0.92**
 - BREAKING CHANGE: `Component` class is no longer a subclass of `View`. To configure the `View` class, set the `Component.View` nested class. HTTP methods like `get` or `post` can still be defined directly on `Component` class, and `Component.as_view()` internally calls `Component.View.as_view()`. (See [Modifying the View class](#modifying-the-view-class))
 
-- The inputs (args, kwargs, slots, context, ...) that you pass to `Component.render()` can be accessed from within `get_context_data`, `get_template_string` and `get_template_name` via `self.input`. (See [Accessing data passed to the component](#accessing-data-passed-to-the-component))
+- The inputs (args, kwargs, slots, context, ...) that you pass to `Component.render()` can be accessed from within `get_context_data`, `get_template` and `get_template_name` via `self.input`. (See [Accessing data passed to the component](#accessing-data-passed-to-the-component))
 
 - Typing: `Component` class supports generics that specify types for `Component.render` (See [Adding type hints with Generics](#adding-type-hints-with-generics))
 
@@ -383,11 +390,13 @@ from django_components import Component, register
 @register("calendar")
 class Calendar(Component):
     # Templates inside `[your apps]/components` dir and `[project root]/components` dir
-    # will be automatically found. To customize which template to use based on context
-    # you can override method `get_template_name` instead of specifying `template_name`.
+    # will be automatically found.
     #
     # `template_name` can be relative to dir where `calendar.py` is, or relative to STATICFILES_DIRS
     template_name = "template.html"
+    # Or
+    def get_template_name(context):
+        return f"template-{context['name']}.html"
 
     # This component takes one parameter, a date string to show in the template
     def get_context_data(self, date):
@@ -1742,7 +1751,7 @@ When you call `Component.render` or `Component.render_to_response`, the inputs t
 This means that you can use `self.input` inside:
 - `get_context_data`
 - `get_template_name`
-- `get_template_string`
+- `get_template`
 
 `self.input` is defined only for the duration of `Component.render`, and raises `RuntimeError` when called outside of this.
 
@@ -3170,6 +3179,26 @@ COMPONENTS = {
 }
 ```
 
+If you want add templates to the cache yourself, you can use `cached_template()`:
+
+```py
+from django_components import cached_template
+
+cached_template("Variable: {{ variable }}")
+
+# You can optionally specify Template class, and other Template inputs:
+class MyTemplate(Template):
+    pass
+
+cached_template(
+    "Variable: {{ variable }}",
+    template_cls=MyTemplate,
+    name=...
+    origin=...
+    engine=...
+)
+```
+
 ### `context_behavior` - Make components isolated (or not)
 
 > NOTE: `context_behavior` and `slot_context_behavior` options were merged in v0.70.
diff --git a/sampleproject/components/calendar/calendar.py b/sampleproject/components/calendar/calendar.py
index beba8890a..b14885838 100644
--- a/sampleproject/components/calendar/calendar.py
+++ b/sampleproject/components/calendar/calendar.py
@@ -3,10 +3,14 @@
 
 @register("calendar")
 class Calendar(Component):
-    # Note that Django will look for templates inside `[your apps]/components` dir and
-    # `[project root]/components` dir. To customize which template to use based on context
-    # you can override def get_template_name() instead of specifying the below variable.
+    # Templates inside `[your apps]/components` dir and `[project root]/components` dir
+    # will be automatically found.
+    #
+    # `template_name` can be relative to dir where `calendar.py` is, or relative to STATICFILES_DIRS
     template_name = "calendar/calendar.html"
+    # Or
+    # def get_template_name(context):
+    #     return f"template-{context['name']}.html"
 
     # This component takes one parameter, a date string to show in the template
     def get_context_data(self, date):
@@ -27,10 +31,14 @@ class Media:
 
 @register("calendar_relative")
 class CalendarRelative(Component):
-    # Note that Django will look for templates inside `[your apps]/components` dir and
-    # `[project root]/components` dir. To customize which template to use based on context
-    # you can override def get_template_name() instead of specifying the below variable.
+    # Templates inside `[your apps]/components` dir and `[project root]/components` dir
+    # will be automatically found.
+    #
+    # `template_name` can be relative to dir where `calendar.py` is, or relative to STATICFILES_DIRS
     template_name = "calendar.html"
+    # Or
+    # def get_template_name(context):
+    #     return f"template-{context['name']}.html"
 
     # This component takes one parameter, a date string to show in the template
     def get_context_data(self, date):
diff --git a/sampleproject/components/nested/calendar/calendar.py b/sampleproject/components/nested/calendar/calendar.py
index 9ec4884c2..19f7d4dd5 100644
--- a/sampleproject/components/nested/calendar/calendar.py
+++ b/sampleproject/components/nested/calendar/calendar.py
@@ -3,10 +3,14 @@
 
 @register("calendar_nested")
 class CalendarNested(Component):
-    # Note that Django will look for templates inside `[your apps]/components` dir and
-    # `[project root]/components` dir. To customize which template to use based on context
-    # you can override def get_template_name() instead of specifying the below variable.
+    # Templates inside `[your apps]/components` dir and `[project root]/components` dir
+    # will be automatically found.
+    #
+    # `template_name` can be relative to dir where `calendar.py` is, or relative to STATICFILES_DIRS
     template_name = "calendar.html"
+    # Or
+    # def get_template_name(context):
+    #     return f"template-{context['name']}.html"
 
     # This component takes one parameter, a date string to show in the template
     def get_context_data(self, date):
diff --git a/sampleproject/components/todo/todo.py b/sampleproject/components/todo/todo.py
index 03b88e17d..803ab222f 100644
--- a/sampleproject/components/todo/todo.py
+++ b/sampleproject/components/todo/todo.py
@@ -2,8 +2,7 @@
 
 
 @register("todo")
-class Calendar(Component):
-    # Note that Django will look for templates inside `[your apps]/components` dir and
-    # `[project root]/components` dir. To customize which template to use based on context
-    # you can override def get_template_name() instead of specifying the below variable.
+class Todo(Component):
+    # Templates inside `[your apps]/components` dir and `[project root]/components` dir
+    # will be automatically found.
     template_name = "todo/todo.html"
diff --git a/src/django_components/__init__.py b/src/django_components/__init__.py
index fa08564ca..65f53b054 100644
--- a/src/django_components/__init__.py
+++ b/src/django_components/__init__.py
@@ -36,6 +36,7 @@
     component_formatter as component_formatter,
     component_shorthand_formatter as component_shorthand_formatter,
 )
+from django_components.template import cached_template as cached_template
 import django_components.types as types
 from django_components.types import (
     EmptyTuple as EmptyTuple,
diff --git a/src/django_components/component.py b/src/django_components/component.py
index 5bcc37bd3..3986546d9 100644
--- a/src/django_components/component.py
+++ b/src/django_components/component.py
@@ -63,6 +63,7 @@
     resolve_fill_nodes,
     resolve_slots,
 )
+from django_components.template import cached_template
 from django_components.utils import gen_id, validate_typed_dict, validate_typed_tuple
 
 # TODO_REMOVE_IN_V1 - Users should use top-level import instead
@@ -154,10 +155,42 @@ class Component(Generic[ArgsType, KwargsType, DataType, SlotsType], metaclass=Co
     # non-null return.
     _class_hash: ClassVar[int]
 
-    template_name: ClassVar[Optional[str]] = None
-    """Relative filepath to the Django template associated with this component."""
-    template: Optional[str] = None
-    """Inlined Django template associated with this component."""
+    template_name: Optional[str] = None
+    """
+    Filepath to the Django template associated with this component.
+
+    The filepath must be relative to either the file where the component class was defined,
+    or one of the roots of `STATIFILES_DIRS`.
+
+    Only one of `template_name`, `get_template_name`, `template` or `get_template` must be defined.
+    """
+
+    def get_template_name(self, context: Context) -> Optional[str]:
+        """
+        Filepath to the Django template associated with this component.
+
+        The filepath must be relative to either the file where the component class was defined,
+        or one of the roots of `STATIFILES_DIRS`.
+
+        Only one of `template_name`, `get_template_name`, `template` or `get_template` must be defined.
+        """
+        return None
+
+    template: Optional[Union[str, Template]] = None
+    """
+    Inlined Django template associated with this component. Can be a plain string or a Template instance.
+
+    Only one of `template_name`, `get_template_name`, `template` or `get_template` must be defined.
+    """
+
+    def get_template(self, context: Context) -> Optional[Union[str, Template]]:
+        """
+        Inlined Django template associated with this component. Can be a plain string or a Template instance.
+
+        Only one of `template_name`, `get_template_name`, `template` or `get_template` must be defined.
+        """
+        return None
+
     js: Optional[str] = None
     """Inlined JS associated with this component."""
     css: Optional[str] = None
@@ -274,28 +307,55 @@ def is_filled(self) -> Dict[str, bool]:
     def get_context_data(self, *args: Any, **kwargs: Any) -> DataType:
         return cast(DataType, {})
 
-    def get_template_name(self, context: Context) -> Optional[str]:
-        return self.template_name
-
-    def get_template_string(self, context: Context) -> Optional[str]:
-        return self.template
-
     # NOTE: When the template is taken from a file (AKA specified via `template_name`),
     # then we leverage Django's template caching. This means that the same instance
     # of Template is reused. This is important to keep in mind, because the implication
     # is that we should treat Templates AND their nodelists as IMMUTABLE.
-    def get_template(self, context: Context) -> Template:
-        template_string = self.get_template_string(context)
-        if template_string is not None:
-            return Template(template_string)
+    def _get_template(self, context: Context) -> Template:
+        # Resolve template name
+        template_name = self.template_name
+        if self.template_name is not None:
+            if self.get_template_name(context) is not None:
+                raise ImproperlyConfigured(
+                    "Received non-null value from both 'template_name' and 'get_template_name' in"
+                    f" Component {type(self).__name__}. Only one of the two must be set."
+                )
+        else:
+            template_name = self.get_template_name(context)
+
+        # Resolve template str
+        template_input = self.template
+        if self.template is not None:
+            if self.get_template(context) is not None:
+                raise ImproperlyConfigured(
+                    "Received non-null value from both 'template' and 'get_template' in"
+                    f" Component {type(self).__name__}. Only one of the two must be set."
+                )
+        else:
+            # TODO_REMOVE_IN_V1 - Remove `self.get_template_string` in v1
+            template_getter = getattr(self, "get_template_string", self.get_template)
+            template_input = template_getter(context)
+
+        if template_name is not None and template_input is not None:
+            raise ImproperlyConfigured(
+                f"Received both 'template_name' and 'template' in Component {type(self).__name__}."
+                " Only one of the two must be set."
+            )
 
-        template_name = self.get_template_name(context)
         if template_name is not None:
             return get_template(template_name).template
 
+        elif template_input is not None:
+            # We got template string, so we convert it to Template
+            if isinstance(template_input, str):
+                template: Template = cached_template(template_input)
+            else:
+                template = template_input
+
+            return template
+
         raise ImproperlyConfigured(
             f"Either 'template_name' or 'template' must be set for Component {type(self).__name__}."
-            f"Note: this attribute is not required if you are overriding the class's `get_template*()` methods."
         )
 
     def render_dependencies(self) -> SafeString:
@@ -606,7 +666,6 @@ def _render_impl(
                 self.on_render_before(context, template)
 
                 rendered_component = template.render(context)
-
                 new_output = self.on_render_after(context, template, rendered_component)
                 rendered_component = new_output if new_output is not None else rendered_component
 
@@ -884,7 +943,7 @@ def _prepare_template(
         # 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)
+        template = component._get_template(context)
         _monkeypatch_template(template)
 
         # Set `Template._dc_is_component_nested` based on whether we're currently INSIDE
diff --git a/src/django_components/slots.py b/src/django_components/slots.py
index 59fa2afa9..113b14514 100644
--- a/src/django_components/slots.py
+++ b/src/django_components/slots.py
@@ -3,6 +3,7 @@
 import re
 from collections import deque
 from dataclasses import dataclass
+from functools import lru_cache
 from typing import (
     TYPE_CHECKING,
     Any,
@@ -27,7 +28,7 @@
 from django.template.exceptions import TemplateSyntaxError
 from django.utils.safestring import SafeString, mark_safe
 
-from django_components.app_settings import ContextBehavior
+from django_components.app_settings import ContextBehavior, app_settings
 from django_components.context import (
     _FILLED_SLOTS_CONTENT_CONTEXT_KEY,
     _INJECT_CONTEXT_KEY_PREFIX,
@@ -37,6 +38,7 @@
 from django_components.expression import RuntimeKwargs, is_identifier
 from django_components.logger import trace_msg
 from django_components.node import BaseNode, NodeTraverse, nodelist_has_content, walk_nodelist
+from django_components.utils import lazy_cache
 
 if TYPE_CHECKING:
     from django_components.component_registry import ComponentRegistry
@@ -332,9 +334,13 @@ def _resolve_kwarg(
         return value
 
 
+# NOTE: There may be more components per template, so using `app_settings.TEMPLATE_CACHE_SIZE`
+# is not entirely correct. However, for now it's not worth it adding a separate setting
+# to control this cache separately. So we use `TEMPLATE_CACHE_SIZE` so the cache is bounded.
+@lazy_cache(lambda: lru_cache(maxsize=app_settings.TEMPLATE_CACHE_SIZE))
 def parse_slot_fill_nodes_from_component_nodelist(
-    component_nodelist: NodeList,
-    ComponentNodeCls: Type[Node],
+    nodes: Tuple[Node, ...],
+    ignored_nodes: Tuple[Type[Node]],
 ) -> List[FillNode]:
     """
     Given a component body (`django.template.NodeList`), find all slot fills,
@@ -355,12 +361,12 @@ def parse_slot_fill_nodes_from_component_nodelist(
     and `fill "second_fill"`.
     """
     fill_nodes: List[FillNode] = []
-    if nodelist_has_content(component_nodelist):
+    if nodelist_has_content(nodes):
         for parse_fn in (
             _try_parse_as_default_fill,
             _try_parse_as_named_fill_tag_set,
         ):
-            curr_fill_nodes = parse_fn(component_nodelist, ComponentNodeCls)
+            curr_fill_nodes = parse_fn(nodes, ignored_nodes)
             if curr_fill_nodes:
                 fill_nodes = curr_fill_nodes
                 break
@@ -375,12 +381,12 @@ def parse_slot_fill_nodes_from_component_nodelist(
 
 
 def _try_parse_as_named_fill_tag_set(
-    nodelist: NodeList,
-    ComponentNodeCls: Type[Node],
+    nodes: Tuple[Node, ...],
+    ignored_nodes: Tuple[Type[Node]],
 ) -> List[FillNode]:
     result = []
     seen_names: Set[str] = set()
-    for node in nodelist:
+    for node in nodes:
         if isinstance(node, FillNode):
             # If the fill name was defined statically, then check for no duplicates.
             maybe_fill_name = node.kwargs.kwargs.get(SLOT_NAME_KWARG)
@@ -402,15 +408,15 @@ def _try_parse_as_named_fill_tag_set(
 
 
 def _try_parse_as_default_fill(
-    nodelist: NodeList,
-    ComponentNodeCls: Type[Node],
+    nodes: Tuple[Node, ...],
+    ignored_nodes: Tuple[Type[Node]],
 ) -> List[FillNode]:
-    nodes_stack: List[Node] = list(nodelist)
+    nodes_stack: List[Node] = list(nodes)
     while nodes_stack:
         node = nodes_stack.pop()
         if isinstance(node, FillNode):
             return []
-        elif isinstance(node, ComponentNodeCls):
+        elif isinstance(node, ignored_nodes):
             # Stop searching here, as fill tags are permitted inside component blocks
             # embedded within a default fill node.
             continue
@@ -419,7 +425,7 @@ def _try_parse_as_default_fill(
     else:
         return [
             FillNode(
-                nodelist=nodelist,
+                nodelist=NodeList(nodes),
                 kwargs=RuntimeKwargs(
                     {
                         # Wrap the default slot name in quotes so it's treated as FilterExpression
diff --git a/src/django_components/template.py b/src/django_components/template.py
new file mode 100644
index 000000000..a185146b9
--- /dev/null
+++ b/src/django_components/template.py
@@ -0,0 +1,42 @@
+from functools import lru_cache
+from typing import Any, Optional, Type, TypeVar
+
+from django.template import Origin, Template
+from django.template.base import UNKNOWN_SOURCE
+
+from django_components.app_settings import app_settings
+from django_components.utils import lazy_cache
+
+TTemplate = TypeVar("TTemplate", bound=Template)
+
+
+# Lazily initialize the cache. The cached function takes only the parts that can
+# affect how the template string is processed - Template class, template string, and engine
+@lazy_cache(lambda: lru_cache(maxsize=app_settings.TEMPLATE_CACHE_SIZE))
+def _create_template(
+    template_cls: Type[TTemplate],
+    template_string: str,
+    engine: Optional[Any] = None,
+) -> TTemplate:
+    return template_cls(template_string, engine=engine)
+
+
+# Central logic for creating Templates from string, so we can cache the results
+def cached_template(
+    template_string: str,
+    template_cls: Optional[Type[Template]] = None,
+    origin: Optional[Origin] = None,
+    name: Optional[str] = None,
+    engine: Optional[Any] = None,
+) -> Template:
+    """Create a Template instance that will be cached as per the `TEMPLATE_CACHE_SIZE` setting."""
+    template = _create_template(template_cls or Template, template_string, engine)
+
+    # Assign the origin and name separately, so the caching doesn't depend on them
+    # Since we might be accessing a template from cache, we want to define these only once
+    if not getattr(template, "_dc_cached", False):
+        template.origin = origin or Origin(UNKNOWN_SOURCE)
+        template.name = name
+        template._dc_cached = True
+
+    return template
diff --git a/src/django_components/templatetags/component_tags.py b/src/django_components/templatetags/component_tags.py
index a3eb51d63..2d3defcd8 100644
--- a/src/django_components/templatetags/component_tags.py
+++ b/src/django_components/templatetags/component_tags.py
@@ -246,7 +246,7 @@ def component(parser: Parser, token: Token, registry: ComponentRegistry, tag_nam
     trace_msg("PARSE", "COMP", result.component_name, tag.id)
 
     body = tag.parse_body()
-    fill_nodes = parse_slot_fill_nodes_from_component_nodelist(body, ComponentNode)
+    fill_nodes = parse_slot_fill_nodes_from_component_nodelist(tuple(body), ignored_nodes=(ComponentNode,))
 
     # Tag all fill nodes as children of this particular component instance
     for node in fill_nodes:
diff --git a/src/django_components/utils.py b/src/django_components/utils.py
index f357d7307..c4d6857e6 100644
--- a/src/django_components/utils.py
+++ b/src/django_components/utils.py
@@ -1,7 +1,8 @@
+import functools
 import sys
 import typing
 from pathlib import Path
-from typing import Any, Callable, List, Mapping, Sequence, Tuple, Union, get_type_hints
+from typing import Any, Callable, List, Mapping, Sequence, Tuple, TypeVar, Union, cast, get_type_hints
 
 from django.utils.autoreload import autoreload_started
 
@@ -166,3 +167,47 @@ def validate_typed_dict(value: Mapping[str, Any], dict_type: Any, prefix: str, k
         # `Component 'name' got unexpected slot keys 'invalid_key'`
         # `Component 'name' got unexpected data keys 'invalid_key'`
         raise TypeError(f"{prefix} got unexpected {kind} keys {formatted_keys}")
+
+
+TFunc = TypeVar("TFunc", bound=Callable)
+
+
+def lazy_cache(
+    make_cache: Callable[[], Callable[[Callable], Callable]],
+) -> Callable[[TFunc], TFunc]:
+    """
+    Decorator that caches the given function similarly to `functools.lru_cache`.
+    But the cache is instantiated only at first invocation.
+
+    `cache` argument is a function that generates the cache function,
+    e.g. `functools.lru_cache()`.
+    """
+    _cached_fn = None
+
+    def decorator(fn: TFunc) -> TFunc:
+        @functools.wraps(fn)
+        def wrapper(*args: Any, **kwargs: Any) -> Any:
+            # Lazily initialize the cache
+            nonlocal _cached_fn
+            if not _cached_fn:
+                # E.g. `lambda: functools.lru_cache(maxsize=app_settings.TEMPLATE_CACHE_SIZE)`
+                cache = make_cache()
+                _cached_fn = cache(fn)
+
+            return _cached_fn(*args, **kwargs)
+
+        # Allow to access the LRU cache methods
+        # See https://stackoverflow.com/a/37654201/9788634
+        wrapper.cache_info = lambda: _cached_fn.cache_info()  # type: ignore
+        wrapper.cache_clear = lambda: _cached_fn.cache_clear()  # type: ignore
+
+        # And allow to remove the cache instance (mostly for tests)
+        def cache_remove() -> None:
+            nonlocal _cached_fn
+            _cached_fn = None
+
+        wrapper.cache_remove = cache_remove  # type: ignore
+
+        return cast(TFunc, wrapper)
+
+    return decorator


Test patch:
diff --git a/tests/test_component.py b/tests/test_component.py
index d2f9f0206..21d45f185 100644
--- a/tests/test_component.py
+++ b/tests/test_component.py
@@ -76,6 +76,35 @@ class CompKwargs(TypedDict, total=False):
         optional: NotRequired[int]
 
 
+# TODO_REMOVE_IN_V1 - Superseded by `self.get_template` in v1
+class ComponentOldTemplateApiTest(BaseTestCase):
+    @parametrize_context_behavior(["django", "isolated"])
+    def test_get_template_string(self):
+        class SimpleComponent(Component):
+            def get_template_string(self, context):
+                content: types.django_html = """
+                    Variable: <strong>{{ variable }}</strong>
+                """
+                return content
+
+            def get_context_data(self, variable=None):
+                return {
+                    "variable": variable,
+                }
+
+            class Media:
+                css = "style.css"
+                js = "script.js"
+
+        rendered = SimpleComponent.render(kwargs={"variable": "test"})
+        self.assertHTMLEqual(
+            rendered,
+            """
+            Variable: <strong>test</strong>
+            """,
+        )
+
+
 class ComponentTest(BaseTestCase):
     class ParentComponent(Component):
         template: types.django_html = """
@@ -123,7 +152,7 @@ class EmptyComponent(Component):
             pass
 
         with self.assertRaises(ImproperlyConfigured):
-            EmptyComponent("empty_component").get_template(Context({}))
+            EmptyComponent("empty_component")._get_template(Context({}))
 
     @parametrize_context_behavior(["django", "isolated"])
     def test_template_string_static_inlined(self):
@@ -152,7 +181,7 @@ class Media:
     @parametrize_context_behavior(["django", "isolated"])
     def test_template_string_dynamic(self):
         class SimpleComponent(Component):
-            def get_template_string(self, context):
+            def get_template(self, context):
                 content: types.django_html = """
                     Variable: <strong>{{ variable }}</strong>
                 """
@@ -225,7 +254,7 @@ def get_template_name(self, context):
         )
 
     @parametrize_context_behavior(["django", "isolated"])
-    def test_allows_to_override_get_template(self):
+    def test_allows_to_return_template(self):
         class TestComponent(Component):
             def get_context_data(self, variable, **attrs):
                 return {
@@ -1037,7 +1066,6 @@ def get_context_data(self, **attrs):
 
 
 class ComponentHookTest(BaseTestCase):
-    @parametrize_context_behavior(["django", "isolated"])
     def test_on_render_before(self):
         class SimpleComponent(Component):
             template: types.django_html = """
@@ -1075,7 +1103,6 @@ def on_render_before(self, context: Context, template: Template) -> None:
         )
 
     # Check that modifying the context or template does nothing
-    @parametrize_context_behavior(["django", "isolated"])
     def test_on_render_after(self):
         captured_content = None
 
diff --git a/tests/test_template.py b/tests/test_template.py
new file mode 100644
index 000000000..aabab6402
--- /dev/null
+++ b/tests/test_template.py
@@ -0,0 +1,67 @@
+from django.template import Context, Template
+from django.test import override_settings
+
+from django_components import Component, cached_template, types
+
+from .django_test_setup import setup_test_config
+from .testutils import BaseTestCase
+
+setup_test_config({"autodiscover": False})
+
+
+class TemplateCacheTest(BaseTestCase):
+    def test_cached_template(self):
+        template_1 = cached_template("Variable: <strong>{{ variable }}</strong>")
+        template_1._test_id = "123"
+
+        template_2 = cached_template("Variable: <strong>{{ variable }}</strong>")
+
+        self.assertEqual(template_2._test_id, "123")
+
+    def test_cached_template_accepts_class(self):
+        class MyTemplate(Template):
+            pass
+
+        template = cached_template("Variable: <strong>{{ variable }}</strong>", MyTemplate)
+        self.assertIsInstance(template, MyTemplate)
+
+    @override_settings(COMPONENTS={"template_cache_size": 2})
+    def test_cache_discards_old_entries(self):
+        template_1 = cached_template("Variable: <strong>{{ variable }}</strong>")
+        template_1._test_id = "123"
+
+        template_2 = cached_template("Variable2")
+        template_2._test_id = "456"
+
+        # Templates 1 and 2 should still be available
+        template_1_copy = cached_template("Variable: <strong>{{ variable }}</strong>")
+        self.assertEqual(template_1_copy._test_id, "123")
+
+        template_2_copy = cached_template("Variable2")
+        self.assertEqual(template_2_copy._test_id, "456")
+
+        # But once we add the third template, template 1 should go
+        cached_template("Variable3")
+
+        template_1_copy2 = cached_template("Variable: <strong>{{ variable }}</strong>")
+        self.assertEqual(hasattr(template_1_copy2, "_test_id"), False)
+
+    def test_component_template_is_cached(self):
+        class SimpleComponent(Component):
+            def get_template(self, context):
+                content: types.django_html = """
+                    Variable: <strong>{{ variable }}</strong>
+                """
+                return content
+
+            def get_context_data(self, variable=None):
+                return {
+                    "variable": variable,
+                }
+
+        comp = SimpleComponent()
+        template_1 = comp._get_template(Context({}))
+        template_1._test_id = "123"
+
+        template_2 = comp._get_template(Context({}))
+        self.assertEqual(template_2._test_id, "123")
diff --git a/tests/test_templatetags_slot_fill.py b/tests/test_templatetags_slot_fill.py
index 37bdc16c4..5a8e0ed41 100644
--- a/tests/test_templatetags_slot_fill.py
+++ b/tests/test_templatetags_slot_fill.py
@@ -434,7 +434,7 @@ def test_component_without_default_slot_refuses_implicit_fill(self):
     @parametrize_context_behavior(["django", "isolated"])
     def test_component_template_cannot_have_multiple_default_slots(self):
         class BadComponent(Component):
-            def get_template(self, context, template_name: Optional[str] = None) -> Template:
+            def get_template(self, context):
                 template_str: types.django_html = """
                     {% load django_components %}
                     <div>
diff --git a/tests/testutils.py b/tests/testutils.py
index f534b49c2..6251e92b1 100644
--- a/tests/testutils.py
+++ b/tests/testutils.py
@@ -24,6 +24,10 @@ def tearDown(self) -> None:
         super().tearDown()
         registry.clear()
 
+        from django_components.template import _create_template
+
+        _create_template.cache_remove()  # type: ignore[attr-defined]
+
 
 request = Mock()
 mock_template = Mock()