EmilStenstrom / django-components

Create simple reusable template components in Django.
MIT License
953 stars 60 forks source link

Allow to render component dependencies without middleware #478

Open JuroOravec opened 2 weeks ago

JuroOravec commented 2 weeks ago

Continuation from this comment https://github.com/EmilStenstrom/django-components/issues/277#issuecomment-2092358246

To refine the previous idea a bit more, I suggest following API for users to render dependencies:

  1. Preferably, use {% component_dependencies %} (or the JS/CSS variants) in the top-level component BEFORE any other tags that could render components.

  2. Alternatively, instead of {% component_dependencies %} (or the JS/CSS variants) users may also use track_dependencies for the same effect:

    track_dependencies(
        context,
        lambda ctx: Template("""
            {% component 'hello' %}{% endcomponent %}
        """).render(ctx),
    ) -> str:
  3. If users need to place {% component_dependencies %} (or the JS/CSS variants) somewhere else than at the beginning of the top-level component, then we will need to the replacement strategy. I suggest to still use the track_dependencies. Basically, if the template given to track_dependencies contains {% component_dependencies %} tag, then we do the replacement strategy. If {% component_dependencies %} is NOT present, then we first render the text and collect all dependencies, and prepend them to the rendered content.

    For users, the API would still be the same as in 2.:

    track_dependencies(
        context,
        lambda ctx: Template("""
            {% component 'hello' %}{% endcomponent %}
            {% component_dependencies %} <-- comp_deps tag found, so replacement strategy used
        """).render(ctx),
    ) -> str:
  4. And if users do not want to call track_dependencies for each template render, they can use the Middleware (as it is currently), with the caveat that it works only for non-streaming responses and of content type text/html.

EmilStenstrom commented 2 weeks ago

To complicate things, I think many will put component_dependencies_js in the footer of the page (where it doesn’t block rendering), and the css-version in the head.

I’m wondering if we’re mixing two concepts here? “Where do I want my link tag” vs “I want to dynamically load my dependencies”.

Is adding a new tag another option?

about the track_dependencies-function, where would the user put it?

JuroOravec commented 2 weeks ago

To complicate things, I think many will put component_dependencies_js in the footer of the page (where it doesn’t block rendering), and the css-version in the head.

Hm, ok, so in that case the point 1. won't work very well from UX perspective.

I’m wondering if we’re mixing two concepts here? “Where do I want my link tag” vs “I want to dynamically load my dependencies”.

Yeah. Btw, by "dynamic loading", we mean the feature that only JS/CSS dependencies of used components are used, right?

Is adding a new tag another option?

I'm thinking of going in opposite direction, unifying all the dependency-rendering logic under a single tag, e.g.:

about the track_dependencies-function, where would the user put it?

At the place where they initialize the template rendering. For example if I have a view that returns a rendered template, I would use it like so:

from django.shortcuts import render

def my_view(request):
    data = do_something()
    ...
    return track_dependencies(
        context,
        lambda ctx: render(request, "my_template.html", ...)
    )

Altho, in this example, there's a question of what is the context - a dict or a Context?

What could work better would be if we defined our own render function. Which could work similarly to Django's render, and would hide the logic of track_dependencies. So we would have

from django_components import render

def my_view(request):
    data = do_something()
    ...
    return render(request, "my_template.html", {"some": "data"})

This, supplying our own render (and render_to_string) functions, is also what I do in django-components fork.

JuroOravec commented 2 weeks ago

Two more things:

  1. So in the discussion above I assume that {% component_dependencies %} renders JS/CSS of ALL components. Because of tag name, it could also suggest that it would only render dependencies of a SINGLE (current) component (AKA "render dependencies of a component" instead of "render component dependencies"). Are you aware of people wanting to render JS/CSS like this, meaning handling JS/CSS per component, instead of grouping them all in a single place? From web development perspective, it makes more sense to group it all.

  2. One extra idea when it comes to rendering dependencies - mostly just throwing it out there so I won't forget, as it probably deserves it's own discussion. (But it's useful to have it mentioned for when thinking about how to design the interface): Currently the idea is to render a single JS/CSS file per component class. We could add a field on the Component class to configure whether to render the dependency file once per class, or once for each render instance. But to allow the latter, we would need to allow users to differenciate between the rendered instances, so we would need to render the JS/CSS deps at the same time (meaning with the same Context) as we use to render the component's HTML. In other words we would allow to use Django templating syntax inside JS and CSS files.

EmilStenstrom commented 1 week ago

I’m wondering if we’re mixing two concepts here? “Where do I want my link tag” vs “I want to dynamically load my dependencies”.

Yeah. Btw, by "dynamic loading", we mean the feature that only JS/CSS dependencies of used components are used, right?

Yes, "only load the dependencies of the components that are used on this page".

Is adding a new tag another option?

I'm thinking of going in opposite direction, unifying all the dependency-rendering logic under a single tag, e.g.:

* Using `type` kwarg to specify whether to render JS/CSS/both:

  * Render both JS and CSS:
    `{% component_dependencies %}`
  * Render only JS:
    `{% component_dependencies type="js" %}`
  * Render only CSS:
    `{% component_dependencies type="css" %}`

I like this, but not sure it's worth it because we would be breaking some existing code. Maybe keeping the old tags as reference to the new ones would work though.

* Using `dynamic=True/False` to decide whether to render ALL registered components (`False`) or only those used (`True`)

  * Render ALL components:
    `{% component_dependencies %}`
  * Render ALL components:
    `{% component_dependencies dynamic=False %}`
  * Render only used components:
    `{% component_dependencies dynamic=True %}`

The only reason you should render all components is if you somehow are expecting them to be in cache for the next page load. You're essentially trading first load time to speed up all successive page loads. This is likely not the right tradeoff for most people.

I like moving towards using this tag to only load used tags. Feels simple and clean.

about the track_dependencies-function, where would the user put it?

At the place where they initialize the template rendering. For example if I have a view that returns a rendered template, I would use it like so: (snip) What could work better would be if we defined our own render function. Which could work similarly to Django's render, and would hide the logic of track_dependencies. So we would have

from django_components import render

def my_view(request):
  data = do_something()
  ...
  return render(request, "my_template.html", {"some": "data"})

Thanks for the explanation, makes sense!

The render method has a very wide surface area, that we then need to keep in sync with for new releases. Not sure I like that responsibilities. Also, overriding it won't work for generic views and other use-cases where you don't use the render function. I think I'm in favor of using component_dependencies then.

dylanjcastillo commented 1 week ago

Just to add something I've noticed with component_dependencies + the dynamic loading middleware. In cases where you dynamically replace content in a page (e.g., using hx-get or hx-post in HTMX), then the required JS and CSS aren't included.

So that means you have to render all the dependencies together. Or, at least, that's what I did when I had this issue. Not sure if we should consider this an issue because I guess it'd be hard to solve from django-components side alone.

EmilStenstrom commented 1 week ago

@dylanjcastillo So maybe there should be a view you can call to update the deps as well? Or to we include the new deps in the response from the server too?

JuroOravec commented 1 week ago

Adding new deps sounds like a complex problem:

My first thoughts:

JuroOravec commented 1 week ago

More thoughts - based on the discussion in https://github.com/EmilStenstrom/django-components/discussions/399, I'm thinking we could have 2 kinds of JS - class-wide and instance-specific. Class-wide is ran once when the component of certain class is first loaded. Instance-specific is ran each time the component is rendered. Class-wide would be like it is currently, meaning it does not have access to context and nor data from get_context_data. On the other hand, the instance-specific JS would be rendered with the same Context as the HTML is.

dylanjcastillo commented 1 week ago

Actually, maybe a better approach, so user doesn't have to do the heavy lifting -> We wrap all the JS / CSS dependencies in a JS script that calls the client-side JS library that manages the dependencies. If a new dependency was sent, it would be loaded onto the page. If it was loaded before, it will be ignored.

This sounds very interesting, but not sure what you mean by the "client-side JS library that manages the dependencies". Is that something we'd create?

In regards to the 2 kinds of JS, I agree. I often ended up doing some weird hacks with event listeners to simulate the Instance-specific one.

JuroOravec commented 1 week ago

@dylanjcastillo Sorry for convoluted language, basically meant some JS script that would manage the dependencies. So yeah, we'd create/own that.

EmilStenstrom commented 6 days ago

I think it makes sense to have a very lightweight JS script that tracks and updates dependencies makes sense.

I wonder if we could make the existing component views just pass their dependencies as HTTP headers? Then the JS could intercept those and update as needed.