From ef242dbd791191f681a32117fa77ddc44b00d2bb Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 20 Dec 2024 13:59:53 -0800 Subject: [PATCH 1/3] Only Once JS prototype --- src/js/src/components.ts | 41 +++++++++++++++++++++++++++++++- src/js/src/index.ts | 2 +- src/js/src/types.ts | 5 ++++ src/reactpy_django/components.py | 23 ++++++++++++++---- src/reactpy_django/utils.py | 1 + 5 files changed, 66 insertions(+), 6 deletions(-) diff --git a/src/js/src/components.ts b/src/js/src/components.ts index e5c62f72..712a24c5 100644 --- a/src/js/src/components.ts +++ b/src/js/src/components.ts @@ -1,4 +1,4 @@ -import { DjangoFormProps } from "./types"; +import { DjangoFormProps, OnlyOnceProps } from "./types"; import React from "react"; import ReactDOM from "react-dom"; /** @@ -62,3 +62,42 @@ export function DjangoForm({ return null; } + +export function OnlyOnceJS({ jsPath, autoRemove }: OnlyOnceProps): null { + React.useEffect(() => { + // Check if the script element already exists + let el = document.head.querySelector( + "script.reactpy-staticfile[src='" + jsPath + "']", + ); + + // Create a new script element, if needed + if (el === null) { + el = document.createElement("script"); + el.className = "reactpy-staticfile"; + if (jsPath) { + el.setAttribute("src", jsPath); + } + document.head.appendChild(el); + } + + // If requested, auto remove the script when it is no longer needed + if (autoRemove) { + // Keep track of the number of ReactPy components that are dependent on this script + let count = Number(el.getAttribute("data-count")); + count += 1; + el.setAttribute("data-count", count.toString()); + + // Remove the script element when the last dependent component is unmounted + return () => { + count -= 1; + if (count === 0) { + el.remove(); + } else { + el.setAttribute("data-count", count.toString()); + } + }; + } + }, []); + + return null; +} diff --git a/src/js/src/index.ts b/src/js/src/index.ts index 1ffff551..d3b08a4c 100644 --- a/src/js/src/index.ts +++ b/src/js/src/index.ts @@ -1,2 +1,2 @@ -export { DjangoForm, bind } from "./components"; +export { OnlyOnceJS, DjangoForm, bind } from "./components"; export { mountComponent } from "./mount"; diff --git a/src/js/src/types.ts b/src/js/src/types.ts index 79b06375..e12ccb0f 100644 --- a/src/js/src/types.ts +++ b/src/js/src/types.ts @@ -23,3 +23,8 @@ export interface DjangoFormProps { onSubmitCallback: (data: Object) => void; formId: string; } + +export interface OnlyOnceProps { + jsPath: string; + autoRemove: boolean; +} diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py index f2ca561c..8cdf5c78 100644 --- a/src/reactpy_django/components.py +++ b/src/reactpy_django/components.py @@ -3,12 +3,14 @@ from __future__ import annotations import json +from pathlib import Path from typing import TYPE_CHECKING, Any, Callable, Union, cast from urllib.parse import urlencode from django.http import HttpRequest +from django.templatetags.static import static from django.urls import reverse -from reactpy import component, hooks, html, utils +from reactpy import component, hooks, html, utils, web from reactpy.types import ComponentType, Key, VdomDict from reactpy_django.exceptions import ViewNotRegisteredError @@ -25,6 +27,12 @@ from reactpy_django.types import AsyncFormEvent, SyncFormEvent, ViewToComponentConstructor, ViewToIframeConstructor +DjangoJS = web.export( + web.module_from_file("reactpy-django", file=Path(__file__).parent / "static" / "reactpy_django" / "client.js"), + ("OnlyOnceJS"), +) + + def view_to_component( view: Callable | View | str, transforms: Sequence[Callable[[VdomDict], Any]] = (), @@ -97,7 +105,9 @@ def django_css(static_path: str, key: Key | None = None) -> ComponentType: return _django_css(static_path=static_path, key=key) -def django_js(static_path: str, key: Key | None = None) -> ComponentType: +def django_js( + static_path: str, only_once: bool = False, only_once_auto_remove: bool = False, key: Key | None = None +) -> ComponentType: """Fetches a JS static file for use within ReactPy. This allows for deferred JS loading. Args: @@ -107,7 +117,9 @@ def django_js(static_path: str, key: Key | None = None) -> ComponentType: immediate siblings """ - return _django_js(static_path=static_path, key=key) + return _django_js( + static_path=static_path, only_once=only_once, only_once_auto_remove=only_once_auto_remove, key=key + ) def django_form( @@ -278,5 +290,8 @@ def _django_css(static_path: str): @component -def _django_js(static_path: str): +def _django_js(static_path: str, only_once: bool, only_once_auto_remove: bool): + if only_once: + return DjangoJS({"jsPath": static(static_path), "autoRemove": only_once_auto_remove}) + return html.script(cached_static_file(static_path)) diff --git a/src/reactpy_django/utils.py b/src/reactpy_django/utils.py index 1ea4f0ea..179fde6c 100644 --- a/src/reactpy_django/utils.py +++ b/src/reactpy_django/utils.py @@ -492,6 +492,7 @@ def wrapper(*args, **kwargs): def cached_static_file(static_path: str) -> str: + """Fetches a static file from Django and caches it for future use.""" from reactpy_django.config import REACTPY_CACHE # Try to find the file within Django's static files From 5e9ab554e39f777835b2372fda7be09594f62c2b Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 21 Dec 2024 13:13:58 -0800 Subject: [PATCH 2/3] prototype that also works with CSS --- src/js/src/components.ts | 38 ++++++++++++++++++++--------- src/js/src/index.ts | 2 +- src/js/src/types.ts | 3 ++- src/reactpy_django/components.py | 41 ++++++++++++++++++++------------ 4 files changed, 56 insertions(+), 28 deletions(-) diff --git a/src/js/src/components.ts b/src/js/src/components.ts index 712a24c5..95c89875 100644 --- a/src/js/src/components.ts +++ b/src/js/src/components.ts @@ -63,31 +63,47 @@ export function DjangoForm({ return null; } -export function OnlyOnceJS({ jsPath, autoRemove }: OnlyOnceProps): null { +export function LoadOnlyOnce({ + path, + nodeName, + autoRemove, +}: OnlyOnceProps): null { React.useEffect(() => { - // Check if the script element already exists - let el = document.head.querySelector( - "script.reactpy-staticfile[src='" + jsPath + "']", - ); + // Check if the element already exists + let el: null | HTMLElement = null; + if (nodeName === "script") { + el = document.head.querySelector( + "script.reactpy-staticfile[src='" + path + "']", + ); + } else if (nodeName === "link") { + el = document.head.querySelector( + "link.reactpy-staticfile[href='" + path + "']", + ); + } else { + throw new Error("Invalid nodeName provided to LoadOnlyOnce"); + } - // Create a new script element, if needed + // Create a new element, if needed if (el === null) { - el = document.createElement("script"); + el = document.createElement(nodeName); el.className = "reactpy-staticfile"; - if (jsPath) { - el.setAttribute("src", jsPath); + if (nodeName === "script") { + el.setAttribute("src", path); + } else if (nodeName === "link") { + el.setAttribute("href", path); + el.setAttribute("rel", "stylesheet"); } document.head.appendChild(el); } - // If requested, auto remove the script when it is no longer needed + // If requested, auto remove the element when it is no longer needed if (autoRemove) { // Keep track of the number of ReactPy components that are dependent on this script let count = Number(el.getAttribute("data-count")); count += 1; el.setAttribute("data-count", count.toString()); - // Remove the script element when the last dependent component is unmounted + // Remove the element when the last dependent component is unmounted return () => { count -= 1; if (count === 0) { diff --git a/src/js/src/index.ts b/src/js/src/index.ts index d3b08a4c..cb365f55 100644 --- a/src/js/src/index.ts +++ b/src/js/src/index.ts @@ -1,2 +1,2 @@ -export { OnlyOnceJS, DjangoForm, bind } from "./components"; +export { LoadOnlyOnce, DjangoForm, bind } from "./components"; export { mountComponent } from "./mount"; diff --git a/src/js/src/types.ts b/src/js/src/types.ts index e12ccb0f..0e826dc0 100644 --- a/src/js/src/types.ts +++ b/src/js/src/types.ts @@ -25,6 +25,7 @@ export interface DjangoFormProps { } export interface OnlyOnceProps { - jsPath: string; + path: string; + nodeName: "script" | "link"; autoRemove: boolean; } diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py index 8cdf5c78..51986cce 100644 --- a/src/reactpy_django/components.py +++ b/src/reactpy_django/components.py @@ -27,12 +27,6 @@ from reactpy_django.types import AsyncFormEvent, SyncFormEvent, ViewToComponentConstructor, ViewToIframeConstructor -DjangoJS = web.export( - web.module_from_file("reactpy-django", file=Path(__file__).parent / "static" / "reactpy_django" / "client.js"), - ("OnlyOnceJS"), -) - - def view_to_component( view: Callable | View | str, transforms: Sequence[Callable[[VdomDict], Any]] = (), @@ -92,7 +86,9 @@ def constructor( return constructor -def django_css(static_path: str, key: Key | None = None) -> ComponentType: +def django_css( + static_path: str, only_once: bool = False, auto_remove: bool = False, key: Key | None = None +) -> ComponentType: """Fetches a CSS static file for use within ReactPy. This allows for deferred CSS loading. Args: @@ -102,11 +98,11 @@ def django_css(static_path: str, key: Key | None = None) -> ComponentType: immediate siblings """ - return _django_css(static_path=static_path, key=key) + return _django_css(static_path=static_path, only_once=only_once, auto_remove=auto_remove, key=key) def django_js( - static_path: str, only_once: bool = False, only_once_auto_remove: bool = False, key: Key | None = None + static_path: str, only_once: bool = False, auto_remove: bool = False, key: Key | None = None ) -> ComponentType: """Fetches a JS static file for use within ReactPy. This allows for deferred JS loading. @@ -117,9 +113,7 @@ def django_js( immediate siblings """ - return _django_js( - static_path=static_path, only_once=only_once, only_once_auto_remove=only_once_auto_remove, key=key - ) + return _django_js(static_path=static_path, only_once=only_once, auto_remove=auto_remove, key=key) def django_form( @@ -285,13 +279,30 @@ def _view_to_iframe( @component -def _django_css(static_path: str): +def _django_css(static_path: str, only_once: bool, auto_remove: bool): + if only_once: + return LoadOnlyOnce({ + "path": static(static_path), + "nodeName": "link", + "autoRemove": auto_remove, + }) + return html.style(cached_static_file(static_path)) @component -def _django_js(static_path: str, only_once: bool, only_once_auto_remove: bool): +def _django_js(static_path: str, only_once: bool, auto_remove: bool): if only_once: - return DjangoJS({"jsPath": static(static_path), "autoRemove": only_once_auto_remove}) + return LoadOnlyOnce({ + "path": static(static_path), + "nodeName": "script", + "autoRemove": auto_remove, + }) return html.script(cached_static_file(static_path)) + + +LoadOnlyOnce = web.export( + web.module_from_file("reactpy-django", file=Path(__file__).parent / "static" / "reactpy_django" / "client.js"), + ("LoadOnlyOnce"), +) From 2b967a3dbf3d8ba74c170264bbda18b2b53a19ed Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Sat, 21 Dec 2024 13:46:23 -0800 Subject: [PATCH 3/3] Add todo comments for my future self --- src/reactpy_django/components.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py index 51986cce..80f8df93 100644 --- a/src/reactpy_django/components.py +++ b/src/reactpy_django/components.py @@ -278,6 +278,9 @@ def _view_to_iframe( ) +# TODO: Consider on_load callback. Store whether something is loaded in the scope. +# TODO: Consider a boolean to change load behavior between text content and file loading +# TODO: Consider doing server side CSS de-duplication to allow for instant loading @component def _django_css(static_path: str, only_once: bool, auto_remove: bool): if only_once: