-
-
Notifications
You must be signed in to change notification settings - Fork 76
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Allow to render component dependencies without middleware #478
Comments
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? |
Hm, ok, so in that case the point 1. won't work very well from UX perspective.
Yeah. Btw, by "dynamic loading", we mean the feature that only JS/CSS dependencies of used components are used, right?
I'm thinking of going in opposite direction, unifying all the dependency-rendering logic under a single tag, e.g.:
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 What could work better would be if we defined our own from django_components import render
def my_view(request):
data = do_something()
...
return render(request, "my_template.html", {"some": "data"}) This, supplying our own |
Two more things:
|
Yes, "only load the dependencies of the components that are used on this page".
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.
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.
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 |
Just to add something I've noticed with 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 |
@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? |
Adding new deps sounds like a complex problem:
My first thoughts:
|
More thoughts - based on the discussion in #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 |
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. |
@dylanjcastillo Sorry for convoluted language, basically meant some JS script that would manage the dependencies. So yeah, we'd create/own that. |
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. |
I have thought about this more and I think we should implement this like this:
|
@EmilStenstrom Sounds good! My naive guess is that we'd do something similar also in the middleware, so the JS script would be aware of the CSS/JS files that were included in the initial response (so it wouldn't load them twice). When it comes to sending data via HTTP headers, I think we'll learn more about the best approach when we try some proof of concept. While I feel like HTTP headers may be the best place, I'm also not sure if the requests can be reliably intercepted. So far I've done it only in browser automation like Playwright. In the browser, there could also be an issue that there's multiple ways how data can be fetched (using Also, with HTTP headers, the header size needs to be considered too. From this thread, we can say that a header may be limited to 8kb. If we'd need to pass on only the component names, NOT the JS/CSS file paths, then that could be maybe 30 characters per component (including formatting), which means a single component could have up to roughly 265 dependencies/subcomponents. But if we'd need to pass also the URL path to the JS/CSS files, then, assuming up to 100 chars per JS/CSS file, and on average only 2 files per component/dependency (1 JS and 1 CSS file), then we'd need maybe around 230 chars per dependency/subcomponent, which would leave us with up to 32-35 dependencies/subcomponents. While 32 subcomponents seems like plenty, I can imagine that if someone was heavily using a So I'd like to also explore the option of passing the data as e.g.
Possibly we could do a "progressive" approach, where if all the dependencies info can fit into an HTTP header, then we use the header, and if NOT, then we insert the * I realized that considering non-HTML cases doesn't make sense, because we're talking about importing JS/CSS, and those only makes sense within the context of a browser. |
Good point with the 8kb limit. I think we can probably build a simple algo that compresses a list of dependency strings in an efficient way: But maybe the easiest way would be to use multiple headers, and not try to push everything into one? That should make this practically limitless. |
Oh, very intesting ideas with both multiple headers and compression, I like that! Didn't know that multiple HTTP headers with the same name are possible. For reference, here's an example from StackOverflow:
So with 3-4 such headers, we could already fit over 100 subcomponents, which feels like it should be sufficient also for the "heavy user of component library" scenario, which means we could avoid having to touch the response HTML 🎉 Going further with the quick mathz, if we assume the pattern that component's JS and CSS files have the same path and the same name, and differ only in the suffix:
And we do some sort of compression algo like @EmilStenstrom suggested, in which:
Then we'd bring it down from 230 to around 150 chars per subcomponent, which would allow for up ~50 subcomponents per header, in which case we could do with just 2 headers to fit 100 dependencies/subcomponents. |
Just for fun I played with GPT-4o to implement this, seems we get a compression ratio of 0.55, that will only grow as we add more urls. urls = [
"/path/to/calendar/index.js",
"/path/to/calendar/style.css",
"/path/to/dropdown/index.js",
"/path/to/dropdown/style.css",
"/path/to/dropdown2/dropdown.css",
"/path/to/dropdown2/dropdown.js",
"/path/to/dropdown3/dropdown.css",
"/path/to/dropdown3/dropdown.js",
"/separate/path/to/calendar/index.js",
"/separate/path/to/calendar/style.css",
"/separate/path/to/dropdown/index.js",
"/separate/path/to/dropdown/style.css",
]
from collections import Counter
# Extract words
words = []
for url in urls:
parts = url.split('/')
for part in parts:
sub_parts = part.split('.')
for sub_part in sub_parts:
if sub_part: # Only add non-empty strings
words.append(sub_part)
# Count frequency of each word
word_counts = Counter(words)
# Sort words by frequency in descending order, most common words first
unique_words = sorted(word_counts.keys(), key=lambda x: -word_counts[x])
# Create a dictionary mapping
word_to_index = {word: index for index, word in enumerate(unique_words)}
# Replace words in URLs with their identifiers
compressed_urls = []
for url in urls:
compressed_url = []
parts = url.split('/')
for part in parts:
sub_parts = part.split('.')
for i, sub_part in enumerate(sub_parts):
if sub_part: # Only add non-empty strings
compressed_url.append(str(word_to_index[sub_part]))
if i != len(sub_parts) - 1: # Don't add '.' after the last sub_part
compressed_url.append('.')
compressed_url.append('/')
compressed_urls.append(''.join(compressed_url[:-1]))
print(urls)
print(compressed_urls)
print(unique_words)
input_length = sum(len(url) for url in urls)
output_length = sum(len(url) for url in compressed_urls) + sum(len(word) + 1 for word in unique_words)
print(f"Input length: {input_length}")
print(f'Output length: {output_length}')
print(f'Compression ratio: {round(output_length / input_length, 2)}')
# Create a reverse mapping from indices to words
index_to_word = {index: word for word, index in word_to_index.items()}
# Reconstruct the original URLs
reconstructed_urls = []
for compressed_url in compressed_urls:
reconstructed_url = []
parts = compressed_url.split('/')
for part in parts:
sub_parts = part.split('.')
for i, sub_part in enumerate(sub_parts):
if sub_part: # Only process non-empty strings
reconstructed_url.append(index_to_word[int(sub_part)])
if i != len(sub_parts) - 1: # Don't add '.' after the last sub_part
reconstructed_url.append('.')
reconstructed_url.append('/')
reconstructed_urls.append(''.join(reconstructed_url[:-1]))
print(reconstructed_urls) $ python url_compressor.py
|
@EmilStenstrom Haha, that's amazing! I just played with it too. First I tried a "second pass", but that didn't work well. Then I tried a couple of built-in compression libraries that python has that ChatGPT suggested. Tried with 57 urls (~29 components):
Also timed them, and got these results
And lol, But those compression libraries produce bytes, so to pass that as an HTTP header, GPT says we need to encode it to base64 after the compression. That makes the stats a bit worse, but zlib is still the best out of all 4, with ~0.295 compression ratio and 0.029 seconds per 57 urls. With a bit of extrapolation, a single HTTP header (8kb) could fit ~460 components. That's definitely enough! But I like that it still leaves the doors open for using multiple headers if that's ever needed. |
I guess you could produce a dictonary for all components on the first load, and then just send the encoded URL:s on all subsequent dynamic loads? I think that makes our algo work best? |
Could you add an example of what you have in mind? |
The code I pasted above does two things:
when I calculated the compression ratio, and always included the full dictionary. But if we can write that to the page in a script tag, the fronted could skip sending the dict again, and we could just pass the list of numbers. |
Hm, not sure about it at the first read. Because it sounds like then this data transmission will be "state-ful" - we would need to be able to tell to the server which words we already have on the frontend. So then we'd need to intercept both requests (frontend --> server) as well as responses (frontend <-- server). But anyway I can look into that once working on this. I think we already have a good idea of how this should work, and I reckon I'll get to the implementation of this in about 1-2 weeks. To sum up what we have so far: See the diagram in Mermaid editor One extra thing I've noticed from the diagram is that when we will be parsing the HTTP header on the frontend, we'll also need to distinguish between:
From frontend's perspective, this could be a simple boolean value ( The question remains how will the server decide whether to flag the reponse as "page" or "fragment"?
Personally I like the first approach more (using config). |
I'm not sure I follow how this would be stateful? Sorry for being very brief, I'll try to explain my thinking in more detail:
Since we're always using a global dictionary over all components, I think we can fully avoid any state management. Since all component view always return just fragments, and we're only intercepting those calls, I don't see why we would need to "mark" different calls as different types either. If we do, let's use the dependencies header, and not send that on the first page load. Please point out if I'm missing something! |
Not sure if I got it 100% right, but my assumption is that the HTML fragments fetched after the page load MAY use different components to render than the components used for the initial page HTML. E.g. if someone triggered HTMX to load a different tab or even a different page altogether. In such case, the "different" components would not be included in the frontend's dictionary, because it would not be detected in the 2nd step ("When any component view is called..."). |
I'm saying that we should base the dictionary on all components in the registry, not just the ones that are rendered on the initial page HTML. That should include all components I think? We could even build the dict and store it in the registry, so as to offset the construction cost on page load? |
Continuation from this comment #277 (comment)
To refine the previous idea a bit more, I suggest following API for users to render dependencies:
Preferably, use
{% component_dependencies %}
(or the JS/CSS variants) in the top-level component BEFORE any other tags that could render components.Alternatively, instead of
{% component_dependencies %}
(or the JS/CSS variants) users may also usetrack_dependencies
for the same effect: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 thetrack_dependencies
. Basically, if the template given totrack_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.:
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 typetext/html
.The text was updated successfully, but these errors were encountered: