diff --git a/src/js/src/components.ts b/src/js/src/components.ts index e5c62f72..95c89875 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,58 @@ export function DjangoForm({ return null; } + +export function LoadOnlyOnce({ + path, + nodeName, + autoRemove, +}: OnlyOnceProps): null { + React.useEffect(() => { + // 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 element, if needed + if (el === null) { + el = document.createElement(nodeName); + el.className = "reactpy-staticfile"; + 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 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 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..cb365f55 100644 --- a/src/js/src/index.ts +++ b/src/js/src/index.ts @@ -1,2 +1,2 @@ -export { 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 79b06375..0e826dc0 100644 --- a/src/js/src/types.ts +++ b/src/js/src/types.ts @@ -23,3 +23,9 @@ export interface DjangoFormProps { onSubmitCallback: (data: Object) => void; formId: string; } + +export interface OnlyOnceProps { + path: string; + nodeName: "script" | "link"; + autoRemove: boolean; +} diff --git a/src/reactpy_django/components.py b/src/reactpy_django/components.py index f2ca561c..80f8df93 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 @@ -84,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: @@ -94,10 +98,12 @@ 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, key: Key | None = None) -> ComponentType: +def django_js( + 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. Args: @@ -107,7 +113,7 @@ 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, auto_remove=auto_remove, key=key) def django_form( @@ -272,11 +278,34 @@ 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): +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): +def _django_js(static_path: str, only_once: bool, auto_remove: bool): + if only_once: + 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"), +) 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