Browse Source

Merge pull request #1134 from zauberzeug/path_improvements

using hierarchical path names to identify JS dependencies
Rodja Trappe 1 year ago
parent
commit
7a695193b2
42 changed files with 426 additions and 378 deletions
  1. 2 7
      examples/custom_vue_component/counter.py
  2. 1 1
      examples/custom_vue_component/main.py
  3. 2 8
      examples/map/leaflet.py
  4. 4 6
      examples/single_page_app/router.py
  5. 5 5
      nicegui/client.py
  6. 130 64
      nicegui/dependencies.py
  7. 55 16
      nicegui/element.py
  8. 2 9
      nicegui/elements/aggrid.py
  9. 2 6
      nicegui/elements/audio.py
  10. 6 18
      nicegui/elements/chart.py
  11. 2 7
      nicegui/elements/chat_message.py
  12. 1 1
      nicegui/elements/choice_element.py
  13. 2 8
      nicegui/elements/colors.py
  14. 2 7
      nicegui/elements/dark_mode.py
  15. 2 7
      nicegui/elements/image.py
  16. 2 8
      nicegui/elements/input.py
  17. 2 6
      nicegui/elements/interactive_image.py
  18. 2 8
      nicegui/elements/joystick.py
  19. 2 7
      nicegui/elements/keyboard.py
  20. 2 7
      nicegui/elements/link.py
  21. 2 7
      nicegui/elements/log.py
  22. 0 6
      nicegui/elements/markdown.js
  23. 4 8
      nicegui/elements/markdown.py
  24. 16 1
      nicegui/elements/mermaid.js
  25. 5 10
      nicegui/elements/mermaid.py
  26. 2 8
      nicegui/elements/plotly.py
  27. 2 7
      nicegui/elements/query.py
  28. 11 19
      nicegui/elements/scene.py
  29. 2 7
      nicegui/elements/select.py
  30. 2 8
      nicegui/elements/table.py
  31. 1 1
      nicegui/elements/textarea.py
  32. 2 7
      nicegui/elements/upload.py
  33. 2 6
      nicegui/elements/video.py
  34. 5 7
      nicegui/functions/refreshable.py
  35. 14 15
      nicegui/nicegui.py
  36. 68 42
      nicegui/templates/index.html
  37. 18 0
      tests/test_aggrid.py
  38. 15 6
      tests/test_chart.py
  39. 9 0
      tests/test_mermaid.py
  40. 8 0
      tests/test_plotly.py
  41. 8 0
      tests/test_scene.py
  42. 2 7
      website/intersection_observer.py

+ 2 - 7
examples/custom_vue_component/counter.py

@@ -1,19 +1,14 @@
-from pathlib import Path
 from typing import Callable, Optional
 
-from nicegui.dependencies import register_vue_component
 from nicegui.element import Element
 
-register_vue_component('counter', Path(__file__).parent / 'counter.js')
 
-
-class Counter(Element):
+class Counter(Element, component='counter.js'):
 
     def __init__(self, title: str, *, on_change: Optional[Callable] = None) -> None:
-        super().__init__('counter')
+        super().__init__()
         self._props['title'] = title
         self.on('change', on_change)
-        self.use_component('counter')
 
     def reset(self) -> None:
         self.run_method('reset')

+ 1 - 1
examples/custom_vue_component/main.py

@@ -14,4 +14,4 @@ with ui.card():
 
 ui.button('Reset', on_click=counter.reset).props('small outline')
 
-ui.run(port=1234)
+ui.run()

+ 2 - 8
examples/map/leaflet.py

@@ -1,18 +1,12 @@
-from pathlib import Path
 from typing import Tuple
 
 from nicegui import ui
-from nicegui.dependencies import register_vue_component
-from nicegui.element import Element
 
-register_vue_component('leaflet', Path(__file__).parent / 'leaflet.js')
 
-
-class leaflet(Element):
+class leaflet(ui.element, component='leaflet.js'):
 
     def __init__(self) -> None:
-        super().__init__('leaflet')
-        self.use_component('leaflet')
+        super().__init__()
         ui.add_head_html('<link href="https://unpkg.com/leaflet@1.6.0/dist/leaflet.css" rel="stylesheet"/>')
         ui.add_head_html('<script src="https://unpkg.com/leaflet@1.6.0/dist/leaflet.js"></script>')
 

+ 4 - 6
examples/single_page_app/router.py

@@ -1,10 +1,10 @@
-from pathlib import Path
 from typing import Awaitable, Callable, Dict, Union
 
 from nicegui import background_tasks, ui
-from nicegui.dependencies import register_vue_component
 
-register_vue_component('router_frame', Path(__file__).parent / 'router_frame.js')
+
+class RouterFrame(ui.element, component='router_frame.js'):
+    pass
 
 
 class Router():
@@ -41,7 +41,5 @@ class Router():
         background_tasks.create(build())
 
     def frame(self) -> ui.element:
-        self.content = ui.element('router_frame') \
-            .on('open', lambda e: self.open(e.args)) \
-            .use_component('router_frame')
+        self.content = RouterFrame().on('open', lambda e: self.open(e.args))
         return self.content

+ 5 - 5
nicegui/client.py

@@ -70,17 +70,17 @@ class Client:
     def build_response(self, request: Request, status_code: int = 200) -> Response:
         prefix = request.headers.get('X-Forwarded-Prefix', request.scope.get('root_path', ''))
         elements = json.dumps({id: element._to_dict() for id, element in self.elements.items()})
-        vue_html, vue_styles, vue_scripts, import_maps, js_imports = generate_resources(prefix, self.elements.values())
+        vue_html, vue_styles, vue_scripts, imports, js_imports = generate_resources(prefix, self.elements.values())
         return templates.TemplateResponse('index.html', {
             'request': request,
             'version': __version__,
             'client_id': str(self.id),
             'elements': elements,
             'head_html': self.head_html,
-            'body_html': f'{vue_styles}\n{self.body_html}\n{vue_html}',
-            'vue_scripts': vue_scripts,
-            'import_maps': import_maps,
-            'js_imports': js_imports,
+            'body_html': '<style>' + '\n'.join(vue_styles) + '</style>\n' + self.body_html + '\n' + '\n'.join(vue_html),
+            'vue_scripts': '\n'.join(vue_scripts),
+            'imports': json.dumps(imports),
+            'js_imports': '\n'.join(js_imports),
             'title': self.page.resolve_title(),
             'viewport': self.page.resolve_viewport(),
             'favicon_url': get_favicon_url(self.page, prefix),

+ 130 - 64
nicegui/dependencies.py

@@ -1,85 +1,151 @@
-import json
+from __future__ import annotations
+
+import hashlib
+from dataclasses import dataclass
 from pathlib import Path
-from typing import Any, Dict, List, Set, Tuple
+from typing import TYPE_CHECKING, Dict, List, Set, Tuple
 
 import vbuild
 
 from . import __version__
-from .element import Element
+from .helpers import KWONLY_SLOTS
+
+if TYPE_CHECKING:
+    from .element import Element
+
+
+@dataclass(**KWONLY_SLOTS)
+class Component:
+    key: str
+    name: str
+    path: Path
+
+    @property
+    def tag(self) -> str:
+        return f'nicegui-{self.name}'
+
+
+@dataclass(**KWONLY_SLOTS)
+class VueComponent(Component):
+    html: str
+    script: str
+    style: str
+
+
+@dataclass(**KWONLY_SLOTS)
+class JsComponent(Component):
+    pass
+
+
+@dataclass(**KWONLY_SLOTS)
+class Library:
+    key: str
+    name: str
+    path: Path
+    expose: bool
 
-vue_components: Dict[str, Any] = {}
-js_components: Dict[str, Any] = {}
-libraries: Dict[str, Any] = {}
 
+vue_components: Dict[str, VueComponent] = {}
+js_components: Dict[str, JsComponent] = {}
+libraries: Dict[str, Library] = {}
 
-def register_vue_component(name: str, path: Path) -> None:
+
+def register_vue_component(path: Path) -> Component:
     """Register a .vue or .js Vue component.
 
-    :param name: unique machine-name (used in element's `use_library`): no space, no special characters
-    :param path: local path
+    Single-file components (.vue) are built right away
+    to delegate this "long" process to the bootstrap phase
+    and to avoid building the component on every single request.
     """
-    suffix = path.suffix.lower()
-    assert suffix in {'.vue', '.js', '.mjs'}, 'Only VUE and JS components are supported.'
-    if suffix == '.vue':
-        assert name not in vue_components, f'Duplicate VUE component name {name}'
-        # The component (in case of .vue) is built right away to:
-        # 1. delegate this "long" process to the bootstrap phase
-        # 2. avoid building the component on every single request
-        vue_components[name] = vbuild.VBuild(name, path.read_text())
-    elif suffix == '.js':
-        assert name not in js_components, f'Duplicate JS component name {name}'
-        js_components[name] = {'name': name, 'path': path}
-
-
-def register_library(name: str, path: Path, *, expose: bool = False) -> None:
-    """Register a new external library.
-
-    :param name: unique machine-name (used in element's `use_library`): no space, no special characters
-    :param path: local path
-    :param expose: if True, this will be exposed as an ESM module but NOT imported
+    key = compute_key(path)
+    name = get_name(path)
+    if path.suffix == '.vue':
+        if key in vue_components and vue_components[key].path == path:
+            return vue_components[key]
+        assert key not in vue_components, f'Duplicate VUE component {key}'
+        v = vbuild.VBuild(name, path.read_text())
+        vue_components[key] = VueComponent(key=key, name=name, path=path, html=v.html, script=v.script, style=v.style)
+        return vue_components[key]
+    if path.suffix == '.js':
+        if key in js_components and js_components[key].path == path:
+            return js_components[key]
+        assert key not in js_components, f'Duplicate JS component {key}'
+        js_components[key] = JsComponent(key=key, name=name, path=path)
+        return js_components[key]
+    raise ValueError(f'Unsupported component type "{path.suffix}"')
+
+
+def register_library(path: Path, *, expose: bool = False) -> Library:
+    """Register a *.js library."""
+    key = compute_key(path)
+    name = get_name(path)
+    if path.suffix in {'.js', '.mjs'}:
+        if key in libraries and libraries[key].path == path:
+            return libraries[key]
+        assert key not in libraries, f'Duplicate js library {key}'
+        libraries[key] = Library(key=key, name=name, path=path, expose=expose)
+        return libraries[key]
+    raise ValueError(f'Unsupported library type "{path.suffix}"')
+
+
+def compute_key(path: Path) -> str:
+    """Compute a key for a given path using a hash function.
+
+    If the path is relative to the NiceGUI base directory, the key is computed from the relative path.
     """
-    assert path.suffix == '.js' or path.suffix == '.mjs', 'Only JS dependencies are supported.'
-    libraries[name] = {'name': name, 'path': path, 'expose': expose}
+    nicegui_base = Path(__file__).parent
+    try:
+        path = path.relative_to(nicegui_base)
+    except ValueError:
+        pass
+    return f'{hashlib.sha256(str(path.parent).encode()).hexdigest()}/{path.name}'
+
+
+def get_name(path: Path) -> str:
+    return path.name.split('.', 1)[0]
 
 
-def generate_resources(prefix: str, elements: List[Element]) -> Tuple[str, str, str, str, str]:
+def generate_resources(prefix: str, elements: List[Element]) -> Tuple[List[str],
+                                                                      List[str],
+                                                                      List[str],
+                                                                      Dict[str, str],
+                                                                      List[str]]:
     done_libraries: Set[str] = set()
     done_components: Set[str] = set()
-    vue_scripts = ''
-    vue_html = ''
-    vue_styles = ''
-    js_imports = ''
-    import_maps = {'imports': {}}
-
-    # Build the importmap structure for exposed libraries.
-    for key in libraries:
-        if key not in done_libraries and libraries[key]['expose']:
-            name = libraries[key]['name']
-            import_maps['imports'][name] = f'{prefix}/_nicegui/{__version__}/library/{key}/include'
+    vue_scripts: List[str] = []
+    vue_html: List[str] = []
+    vue_styles: List[str] = []
+    js_imports: List[str] = []
+    imports: Dict[str, str] = {}
+
+    # build the importmap structure for exposed libraries
+    for key, library in libraries.items():
+        if key not in done_libraries and library.expose:
+            imports[library.name] = f'{prefix}/_nicegui/{__version__}/libraries/{key}'
             done_libraries.add(key)
-    # Build the none optimized component (ie, the vue component).
-    for key in vue_components:
+
+    # build the none-optimized component (i.e. the Vue component)
+    for key, component in vue_components.items():
         if key not in done_components:
-            vue_html += f'{vue_components[key].html}\n'
-            vue_scripts += f'{vue_components[key].script.replace("Vue.component", "app.component", 1)}\n'
-            vue_styles += f'{vue_components[key].style}\n'
+            vue_html.append(component.html)
+            vue_scripts.append(component.script.replace(f"Vue.component('{component.name}',",
+                                                        f"app.component('{component.tag}',", 1))
+            vue_styles.append(component.style)
             done_components.add(key)
 
-    # Build the resources associated with the elements.
+    # build the resources associated with the elements
     for element in elements:
-        for key in element.libraries:
-            if key in libraries and key not in done_libraries:
-                if not libraries[key]['expose']:
-                    js_imports += f'import "{prefix}/_nicegui/{__version__}/library/{key}/include";\n'
-                done_libraries.add(key)
-        for key in element.components:
-            if key in js_components and key not in done_components:
-                name = js_components[key]['name']
-                var = key.replace('-', '_')
-                js_imports += f'import {{ default as {var} }} from "{prefix}/_nicegui/{__version__}/components/{key}";\n'
-                js_imports += f'app.component("{name}", {var});\n'
-                done_components.add(key)
-
-    vue_styles = f'<style>{vue_styles}</style>'
-    import_maps = f'<script type="importmap">{json.dumps(import_maps)}</script>'
-    return vue_html, vue_styles, vue_scripts, import_maps, js_imports
+        for library in element.libraries:
+            if library.key not in done_libraries:
+                if not library.expose:
+                    url = f'{prefix}/_nicegui/{__version__}/libraries/{library.key}'
+                    js_imports.append(f'import "{url}";')
+                done_libraries.add(library.key)
+        if element.component:
+            component = element.component
+            if component.key not in done_components and component.path.suffix.lower() == '.js':
+                url = f'{prefix}/_nicegui/{__version__}/components/{component.key}'
+                js_imports.append(f'import {{ default as {component.name} }} from "{url}";')
+                js_imports.append(f'app.component("{component.tag}", {component.name});')
+                done_components.add(component.key)
+    return vue_html, vue_styles, vue_scripts, imports, js_imports

+ 55 - 16
nicegui/element.py

@@ -1,7 +1,9 @@
 from __future__ import annotations
 
+import inspect
 import re
 from copy import deepcopy
+from pathlib import Path
 from typing import TYPE_CHECKING, Any, Callable, Dict, Iterator, List, Optional, Union
 
 from typing_extensions import Self
@@ -9,6 +11,7 @@ from typing_extensions import Self
 from nicegui import json
 
 from . import binding, events, globals, outbox, storage
+from .dependencies import JsComponent, Library, register_library, register_vue_component
 from .elements.mixins.visibility import Visibility
 from .event_listener import EventListener
 from .slot import Slot
@@ -21,8 +24,12 @@ PROPS_PATTERN = re.compile(r'([:\w\-]+)(?:=(?:("[^"\\]*(?:\\.[^"\\]*)*")|([\w\-.
 
 
 class Element(Visibility):
+    component: Optional[JsComponent] = None
+    libraries: List[Library] = []
+    extra_libraries: List[Library] = []
+    exposed_libraries: List[Library] = []
 
-    def __init__(self, tag: str, *, _client: Optional[Client] = None) -> None:
+    def __init__(self, tag: Optional[str] = None, *, _client: Optional[Client] = None) -> None:
         """Generic Element
 
         This class is the base class for all other UI elements.
@@ -35,14 +42,12 @@ class Element(Visibility):
         self.client = _client or globals.get_client()
         self.id = self.client.next_element_id
         self.client.next_element_id += 1
-        self.tag = tag
+        self.tag = tag if tag else self.component.tag if self.component else 'div'
         self._classes: List[str] = []
         self._style: Dict[str, str] = {}
         self._props: Dict[str, Any] = {'key': self.id}  # HACK: workaround for #600 and #898
         self._event_listeners: Dict[str, EventListener] = {}
         self._text: Optional[str] = None
-        self.components: List[str] = []
-        self.libraries: List[str] = []
         self.slots: Dict[str, Slot] = {}
         self.default_slot = self.add_slot('default')
 
@@ -59,6 +64,41 @@ class Element(Visibility):
         if self.parent_slot:
             outbox.enqueue_update(self.parent_slot.parent)
 
+    def __init_subclass__(cls, *,
+                          component: Union[str, Path, None] = None,
+                          libraries: List[Union[str, Path]] = [],
+                          exposed_libraries: List[Union[str, Path]] = [],
+                          extra_libraries: List[Union[str, Path]] = [],
+                          ) -> None:
+        super().__init_subclass__()
+        base = Path(inspect.getfile(cls)).parent
+
+        def glob_absolute_paths(file: Union[str, Path]) -> List[Path]:
+            path = Path(file)
+            if not path.is_absolute():
+                path = base / path
+            return sorted(path.parent.glob(path.name), key=lambda p: p.stem)
+
+        cls.component = None
+        if component:
+            for path in glob_absolute_paths(component):
+                cls.component = register_vue_component(path)
+
+        cls.libraries = []
+        for library in libraries:
+            for path in glob_absolute_paths(library):
+                cls.libraries.append(register_library(path))
+
+        cls.extra_libraries = []
+        for library in extra_libraries:
+            for path in glob_absolute_paths(library):
+                cls.extra_libraries.append(register_library(path))
+
+        cls.exposed_libraries = []
+        for library in exposed_libraries:
+            for path in glob_absolute_paths(library):
+                cls.exposed_libraries.append(register_library(path, expose=True))
+
     def add_slot(self, name: str, template: Optional[str] = None) -> Slot:
         """Add a slot to the element.
 
@@ -97,8 +137,17 @@ class Element(Visibility):
             'text': self._text,
             'slots': self._collect_slot_dict(),
             'events': [listener.to_dict() for listener in self._event_listeners.values()],
-            'libraries': self.libraries,
-            'components': self.components,
+            'component': {
+                'key': self.component.key,
+                'name': self.component.name,
+                'tag': self.component.tag
+            } if self.component else None,
+            'libraries': [
+                {
+                    'key': library.key,
+                    'name': library.name,
+                } for library in self.libraries
+            ],
         }
 
     @staticmethod
@@ -306,13 +355,3 @@ class Element(Visibility):
 
         Can be overridden to perform cleanup.
         """
-
-    def use_component(self, name: str) -> Self:
-        """Register a ``*.js`` Vue component to be used by this element."""
-        self.components.append(name)
-        return self
-
-    def use_library(self, name: str) -> Self:
-        """Register a JavaScript library to be used by this element."""
-        self.libraries.append(name)
-        return self

+ 2 - 9
nicegui/elements/aggrid.py

@@ -1,17 +1,12 @@
 from __future__ import annotations
 
-from pathlib import Path
 from typing import Dict, List, Optional, cast
 
-from ..dependencies import register_library, register_vue_component
 from ..element import Element
 from ..functions.javascript import run_javascript
 
-register_vue_component('aggrid', Path(__file__).parent / 'aggrid.js')
-register_library('aggrid', Path(__file__).parent / 'lib' / 'aggrid' / 'ag-grid-community.min.js')
 
-
-class AgGrid(Element):
+class AgGrid(Element, component='aggrid.js', libraries=['lib/aggrid/ag-grid-community.min.js']):
 
     def __init__(self, options: Dict, *, html_columns: List[int] = [], theme: str = 'balham') -> None:
         """AG Grid
@@ -24,12 +19,10 @@ class AgGrid(Element):
         :param html_columns: list of columns that should be rendered as HTML (default: `[]`)
         :param theme: AG Grid theme (default: 'balham')
         """
-        super().__init__('aggrid')
+        super().__init__()
         self._props['options'] = options
         self._props['html_columns'] = html_columns
         self._classes = ['nicegui-aggrid', f'ag-theme-{theme}']
-        self.use_component('aggrid')
-        self.use_library('aggrid')
 
     @staticmethod
     def from_pandas(df: 'pandas.DataFrame', *, theme: str = 'balham') -> AgGrid:

+ 2 - 6
nicegui/elements/audio.py

@@ -3,13 +3,10 @@ from pathlib import Path
 from typing import Union
 
 from .. import globals
-from ..dependencies import register_vue_component
 from ..element import Element
 
-register_vue_component('audio', Path(__file__).parent / 'audio.js')
 
-
-class Audio(Element):
+class Audio(Element, component='audio.js'):
 
     def __init__(self, src: Union[str, Path], *,
                  controls: bool = True,
@@ -29,7 +26,7 @@ class Audio(Element):
         See `here <https://developer.mozilla.org/en-US/docs/Web/HTML/Element/audio#events>`_
         for a list of events you can subscribe to using the generic event subscription `on()`.
         """
-        super().__init__('audio')
+        super().__init__()
         if Path(src).is_file():
             src = globals.app.add_media_file(local_file=src)
         self._props['src'] = src
@@ -37,7 +34,6 @@ class Audio(Element):
         self._props['autoplay'] = autoplay
         self._props['muted'] = muted
         self._props['loop'] = loop
-        self.use_component('audio')
 
         if type:
             url = f'https://github.com/zauberzeug/nicegui/pull/624'

+ 6 - 18
nicegui/elements/chart.py

@@ -1,20 +1,12 @@
-from pathlib import Path
 from typing import Dict, List
 
-from ..dependencies import register_library, register_vue_component
 from ..element import Element
 
-register_vue_component('chart', Path(__file__).parent / 'chart.js')
 
-core_dependencies: List[Path] = []
-for path in sorted((Path(__file__).parent / 'lib' / 'highcharts').glob('*.js'), key=lambda p: p.stem):
-    register_library(path.stem, path)
-    core_dependencies.append(path)
-for path in sorted((Path(__file__).parent / 'lib' / 'highcharts' / 'modules').glob('*.js'), key=lambda p: p.stem):
-    register_library(path.stem, path)
-
-
-class Chart(Element):
+class Chart(Element,
+            component='chart.js',
+            libraries=['lib/highcharts/*.js'],
+            extra_libraries=['lib/highcharts/modules/*.js']):
 
     def __init__(self, options: Dict, *, type: str = 'chart', extras: List[str] = []) -> None:
         """Chart
@@ -30,15 +22,11 @@ class Chart(Element):
         :param type: chart type (e.g. "chart", "stockChart", "mapChart", ...; default: "chart")
         :param extras: list of extra dependencies to include (e.g. "annotations", "arc-diagram", "solid-gauge", ...)
         """
-        super().__init__('chart')
+        super().__init__()
         self._props['type'] = type
         self._props['options'] = options
         self._props['extras'] = extras
-        self.use_component('chart')
-        for dependency in core_dependencies:
-            self.use_library(dependency.stem)
-        for extra in extras:
-            self.use_library(extra)
+        self.libraries.extend(library for library in self.extra_libraries if library.path.stem in extras)
 
     @property
     def options(self) -> Dict:

+ 2 - 7
nicegui/elements/chat_message.py

@@ -1,14 +1,10 @@
 import html
-from pathlib import Path
 from typing import List, Optional, Union
 
-from ..dependencies import register_vue_component
 from ..element import Element
 
-register_vue_component('chat_message', Path(__file__).parent / 'chat_message.js')
 
-
-class ChatMessage(Element):
+class ChatMessage(Element, component='chat_message.js'):
 
     def __init__(self,
                  text: Union[str, List[str]], *,
@@ -31,8 +27,7 @@ class ChatMessage(Element):
         :param sent: render as a sent message (so from current user) (default: False)
         :param text_html: render text as HTML (default: False)
         """
-        super().__init__('chat_message')
-        self.use_component('chat_message')
+        super().__init__()
 
         if isinstance(text, str):
             text = [text]

+ 1 - 1
nicegui/elements/choice_element.py

@@ -6,7 +6,7 @@ from .mixins.value_element import ValueElement
 class ChoiceElement(ValueElement):
 
     def __init__(self, *,
-                 tag: str,
+                 tag: Optional[str] = None,
                  options: Union[List, Dict],
                  value: Any,
                  on_change: Optional[Callable[..., Any]] = None,

+ 2 - 8
nicegui/elements/colors.py

@@ -1,12 +1,7 @@
-from pathlib import Path
-
-from ..dependencies import register_vue_component
 from ..element import Element
 
-register_vue_component('colors', Path(__file__).parent / 'colors.js')
-
 
-class Colors(Element):
+class Colors(Element, component='colors.js'):
 
     def __init__(self, *,
                  primary='#5898d4',
@@ -21,8 +16,7 @@ class Colors(Element):
 
         Sets the main colors (primary, secondary, accent, ...) used by `Quasar <https://quasar.dev/>`_.
         """
-        super().__init__('colors')
-        self.use_component('colors')
+        super().__init__()
         self._props['primary'] = primary
         self._props['secondary'] = secondary
         self._props['accent'] = accent

+ 2 - 7
nicegui/elements/dark_mode.py

@@ -1,13 +1,9 @@
-from pathlib import Path
 from typing import Optional
 
-from ..dependencies import register_vue_component
 from .mixins.value_element import ValueElement
 
-register_vue_component('dark_mode', Path(__file__).parent / 'dark_mode.js')
 
-
-class DarkMode(ValueElement):
+class DarkMode(ValueElement, component='dark_mode.js'):
     VALUE_PROP = 'value'
 
     def __init__(self, value: Optional[bool] = False) -> None:
@@ -20,8 +16,7 @@ class DarkMode(ValueElement):
 
         :param value: Whether dark mode is enabled. If None, dark mode is set to auto.
         """
-        super().__init__(tag='dark_mode', value=value, on_value_change=None)
-        self.use_component('dark_mode')
+        super().__init__(value=value, on_value_change=None)
 
     def enable(self) -> None:
         """Enable dark mode."""

+ 2 - 7
nicegui/elements/image.py

@@ -1,14 +1,10 @@
 from pathlib import Path
 from typing import Union
 
-from nicegui.dependencies import register_vue_component
-
 from .mixins.source_element import SourceElement
 
-register_vue_component('image', Path(__file__).parent / 'image.js')
-
 
-class Image(SourceElement):
+class Image(SourceElement, component='image.js'):
 
     def __init__(self, source: Union[str, Path] = '') -> None:
         """Image
@@ -17,5 +13,4 @@ class Image(SourceElement):
 
         :param source: the source of the image; can be a URL, local file path or a base64 string
         """
-        super().__init__(tag='image', source=source)
-        self.use_component('image')
+        super().__init__(source=source)

+ 2 - 8
nicegui/elements/input.py

@@ -1,15 +1,11 @@
-from pathlib import Path
 from typing import Any, Callable, Dict, List, Optional
 
-from ..dependencies import register_vue_component
 from .icon import Icon
 from .mixins.disableable_element import DisableableElement
 from .mixins.validation_element import ValidationElement
 
-register_vue_component('nicegui-input', Path(__file__).parent / 'input.js')
 
-
-class Input(ValidationElement, DisableableElement):
+class Input(ValidationElement, DisableableElement, component='input.js'):
     VALUE_PROP: str = 'value'
     LOOPBACK = False
 
@@ -42,7 +38,7 @@ class Input(ValidationElement, DisableableElement):
         :param autocomplete: optional list of strings for autocompletion
         :param validation: dictionary of validation rules, e.g. ``{'Too long!': lambda value: len(value) < 3}``
         """
-        super().__init__(tag='nicegui-input', value=value, on_value_change=on_change, validation=validation)
+        super().__init__(value=value, on_value_change=on_change, validation=validation)
         if label is not None:
             self._props['label'] = label
         if placeholder is not None:
@@ -59,8 +55,6 @@ class Input(ValidationElement, DisableableElement):
 
         self._props['autocomplete'] = autocomplete or []
 
-        self.use_component('nicegui-input')
-
     def set_autocomplete(self, autocomplete: Optional[List[str]]) -> None:
         """Set the autocomplete list."""
         self._props['autocomplete'] = autocomplete

+ 2 - 6
nicegui/elements/interactive_image.py

@@ -3,15 +3,12 @@ from __future__ import annotations
 from pathlib import Path
 from typing import Any, Callable, List, Optional, Union
 
-from ..dependencies import register_vue_component
 from ..events import GenericEventArguments, MouseEventArguments, handle_event
 from .mixins.content_element import ContentElement
 from .mixins.source_element import SourceElement
 
-register_vue_component('interactive_image', Path(__file__).parent / 'interactive_image.js')
 
-
-class InteractiveImage(SourceElement, ContentElement):
+class InteractiveImage(SourceElement, ContentElement, component='interactive_image.js'):
     CONTENT_PROP = 'content'
 
     def __init__(self,
@@ -35,10 +32,9 @@ class InteractiveImage(SourceElement, ContentElement):
         :param events: list of JavaScript events to subscribe to (default: `['click']`)
         :param cross: whether to show crosshairs (default: `False`)
         """
-        super().__init__(tag='interactive_image', source=source, content=content)
+        super().__init__(source=source, content=content)
         self._props['events'] = events
         self._props['cross'] = cross
-        self.use_component('interactive_image')
 
         def handle_mouse(e: GenericEventArguments) -> None:
             if on_mouse is None:

+ 2 - 8
nicegui/elements/joystick.py

@@ -1,15 +1,10 @@
-from pathlib import Path
 from typing import Any, Callable, Optional
 
-from ..dependencies import register_library, register_vue_component
 from ..element import Element
 from ..events import GenericEventArguments, JoystickEventArguments, handle_event
 
-register_vue_component('joystick', Path(__file__).parent / 'joystick.vue')
-register_library('nipplejs', Path(__file__).parent / 'lib' / 'nipplejs' / 'nipplejs.js')
 
-
-class Joystick(Element):
+class Joystick(Element, component='joystick.vue', libraries=['lib/nipplejs/nipplejs.js']):
 
     def __init__(self, *,
                  on_start: Optional[Callable[..., Any]] = None,
@@ -27,8 +22,7 @@ class Joystick(Element):
         :param throttle: throttle interval in seconds for the move event (default: 0.05)
         :param options: arguments like `color` which should be passed to the `underlying nipple.js library <https://github.com/yoannmoinet/nipplejs#options>`_
         """
-        super().__init__('joystick')
-        self.use_library('nipplejs')
+        super().__init__()
         self._props['options'] = options
         self.active = False
 

+ 2 - 7
nicegui/elements/keyboard.py

@@ -1,18 +1,14 @@
-from pathlib import Path
 from typing import Any, Callable, List
 
 from typing_extensions import Literal
 
 from ..binding import BindableProperty
-from ..dependencies import register_vue_component
 from ..element import Element
 from ..events import (GenericEventArguments, KeyboardAction, KeyboardKey, KeyboardModifiers, KeyEventArguments,
                       handle_event)
 
-register_vue_component('keyboard', Path(__file__).parent / 'keyboard.js')
 
-
-class Keyboard(Element):
+class Keyboard(Element, component='keyboard.js'):
     active = BindableProperty()
 
     def __init__(self,
@@ -30,14 +26,13 @@ class Keyboard(Element):
         :param repeating: boolean flag indicating whether held keys should be sent repeatedly (default: `True`)
         :param ignore: ignore keys when one of these element types is focussed (default: `['input', 'select', 'button', 'textarea']`)
         """
-        super().__init__('keyboard')
+        super().__init__()
         self.key_handler = on_key
         self.active = active
         self._props['events'] = ['keydown', 'keyup']
         self._props['repeating'] = repeating
         self._props['ignore'] = ignore
         self.on('key', self.handle_key)
-        self.use_component('keyboard')
 
     def handle_key(self, e: GenericEventArguments) -> None:
         if not self.active:

+ 2 - 7
nicegui/elements/link.py

@@ -1,15 +1,11 @@
-from pathlib import Path
 from typing import Any, Callable, Union
 
 from .. import globals
-from ..dependencies import register_vue_component
 from ..element import Element
 from .mixins.text_element import TextElement
 
-register_vue_component('link', Path(__file__).parent / 'link.js')
 
-
-class Link(TextElement):
+class Link(TextElement, component='link.js'):
 
     def __init__(self,
                  text: str = '',
@@ -27,7 +23,7 @@ class Link(TextElement):
         :param target: page function, NiceGUI element on the same page or string that is a an absolute URL or relative path from base URL
         :param new_tab: open link in new tab (default: False)
         """
-        super().__init__(tag='link', text=text)
+        super().__init__(text=text)
         if isinstance(target, str):
             self._props['href'] = target
         elif isinstance(target, Element):
@@ -36,7 +32,6 @@ class Link(TextElement):
             self._props['href'] = globals.page_routes[target]
         self._props['target'] = '_blank' if new_tab else '_self'
         self._classes = ['nicegui-link']
-        self.use_component('link')
 
 
 class LinkTarget(Element):

+ 2 - 7
nicegui/elements/log.py

@@ -1,15 +1,11 @@
 import urllib.parse
 from collections import deque
-from pathlib import Path
 from typing import Any, Optional
 
-from ..dependencies import register_vue_component
 from ..element import Element
 
-register_vue_component('log', Path(__file__).parent / 'log.js')
 
-
-class Log(Element):
+class Log(Element, component='log.js'):
 
     def __init__(self, max_lines: Optional[int] = None) -> None:
         """Log view
@@ -18,12 +14,11 @@ class Log(Element):
 
         :param max_lines: maximum number of lines before dropping oldest ones (default: `None`)
         """
-        super().__init__('log')
+        super().__init__()
         self._props['max_lines'] = max_lines
         self._props['lines'] = ''
         self._classes = ['nicegui-log']
         self.lines: deque[str] = deque(maxlen=max_lines)
-        self.use_component('log')
         self.total_count: int = 0
 
     def push(self, line: Any) -> None:

+ 0 - 6
nicegui/elements/markdown.js

@@ -37,9 +37,3 @@ export default {
     },
   },
 };
-
-function decodeHtml(html) {
-  const txt = document.createElement("textarea");
-  txt.innerHTML = html;
-  return txt.value;
-}

+ 4 - 8
nicegui/elements/markdown.py

@@ -1,19 +1,16 @@
 import os
 import re
 from functools import lru_cache
-from pathlib import Path
 from typing import List
 
 import markdown2
 from pygments.formatters import HtmlFormatter
 
-from ..dependencies import register_vue_component
+from .mermaid import Mermaid
 from .mixins.content_element import ContentElement
 
-register_vue_component('markdown', Path(__file__).parent / 'markdown.js')
 
-
-class Markdown(ContentElement):
+class Markdown(ContentElement, component='markdown.js'):
 
     def __init__(self, content: str = '', *, extras: List[str] = ['fenced-code-blocks', 'tables']) -> None:
         """Markdown Element
@@ -24,13 +21,12 @@ class Markdown(ContentElement):
         :param extras: list of `markdown2 extensions <https://github.com/trentm/python-markdown2/wiki/Extras#implemented-extras>`_ (default: `['fenced-code-blocks', 'tables']`)
         """
         self.extras = extras
-        super().__init__(tag='markdown', content=content)
+        super().__init__(content=content)
         self._classes = ['nicegui-markdown']
         self._props['codehilite_css'] = HtmlFormatter(nobackground=True).get_style_defs('.codehilite')
-        self.use_component('markdown')
         if 'mermaid' in extras:
             self._props['use_mermaid'] = True
-            self.use_library('mermaid')
+            self.libraries.append(Mermaid.exposed_libraries[0])
 
     def on_content_change(self, content: str) -> None:
         html = prepare_content(content, extras=' '.join(self.extras))

+ 16 - 1
nicegui/elements/mermaid.js

@@ -1,14 +1,29 @@
 import mermaid from "mermaid";
+
+let is_running = false;
+const queue = [];
+
 export default {
   template: `<div></div>`,
+  data: () => ({
+    last_content: "",
+  }),
   mounted() {
     this.update(this.content);
   },
   methods: {
     async update(content) {
+      if (this.last_content === content) return;
+      this.last_content = content;
       this.$el.innerHTML = content;
       this.$el.removeAttribute("data-processed");
-      await mermaid.run({ nodes: [this.$el] });
+      queue.push(this.$el);
+      if (is_running) return;
+      is_running = true;
+      while (queue.length) {
+        await mermaid.run({ nodes: [queue.shift()] });
+      }
+      is_running = false;
     },
   },
   props: {

+ 5 - 10
nicegui/elements/mermaid.py

@@ -1,13 +1,10 @@
-from pathlib import Path
-
-from ..dependencies import register_library, register_vue_component
 from .mixins.content_element import ContentElement
 
-register_vue_component('mermaid', Path(__file__).parent / 'mermaid.js')
-register_library('mermaid', Path(__file__).parent / 'lib' / 'mermaid' / 'mermaid.esm.min.mjs', expose=True)
-
 
-class Mermaid(ContentElement):
+class Mermaid(ContentElement,
+              component='mermaid.js',
+              exposed_libraries=['lib/mermaid/mermaid.esm.min.mjs'],
+              extra_libraries=['lib/mermaid/*.js']):
     CONTENT_PROP = 'content'
 
     def __init__(self, content: str) -> None:
@@ -18,9 +15,7 @@ class Mermaid(ContentElement):
 
         :param content: the Mermaid content to be displayed
         '''
-        super().__init__(tag='mermaid', content=content)
-        self.use_component('mermaid')
-        self.use_library('mermaid')
+        super().__init__(content=content)
 
     def on_content_change(self, content: str) -> None:
         self._props[self.CONTENT_PROP] = content.strip()

+ 2 - 8
nicegui/elements/plotly.py

@@ -1,16 +1,11 @@
-from pathlib import Path
 from typing import Dict, Union
 
 import plotly.graph_objects as go
 
-from ..dependencies import register_library, register_vue_component
 from ..element import Element
 
-register_vue_component('plotly', Path(__file__).parent / 'plotly.vue')
-register_library('plotly', Path(__file__).parent / 'lib' / 'plotly' / 'plotly.min.js')
 
-
-class Plotly(Element):
+class Plotly(Element, component='plotly.vue', libraries=['lib/plotly/plotly.min.js']):
 
     def __init__(self, figure: Union[Dict, go.Figure]) -> None:
         """Plotly Element
@@ -27,8 +22,7 @@ class Plotly(Element):
         :param figure: Plotly figure to be rendered. Can be either a `go.Figure` instance, or
                        a `dict` object with keys `data`, `layout`, `config` (optional).
         """
-        super().__init__('plotly')
-        self.use_library('plotly')
+        super().__init__()
 
         self.figure = figure
         self.update()

+ 2 - 7
nicegui/elements/query.py

@@ -1,24 +1,19 @@
-from pathlib import Path
 from typing import Optional
 
 from typing_extensions import Self
 
-from ..dependencies import register_vue_component
 from ..element import Element
 from ..globals import get_client
 
-register_vue_component('query', Path(__file__).parent / 'query.js')
 
-
-class Query(Element):
+class Query(Element, component='query.js'):
 
     def __init__(self, selector: str) -> None:
-        super().__init__('query')
+        super().__init__()
         self._props['selector'] = selector
         self._props['classes'] = []
         self._props['style'] = {}
         self._props['props'] = {}
-        self.use_component('query')
 
     def classes(self, add: Optional[str] = None, *, remove: Optional[str] = None, replace: Optional[str] = None) \
             -> Self:

+ 11 - 19
nicegui/elements/scene.py

@@ -1,22 +1,12 @@
 from dataclasses import dataclass
-from pathlib import Path
 from typing import Any, Callable, Dict, List, Optional, Union
 
 from .. import binding, globals
-from ..dependencies import register_library, register_vue_component
 from ..element import Element
 from ..events import GenericEventArguments, SceneClickEventArguments, SceneClickHit, handle_event
 from ..helpers import KWONLY_SLOTS
 from .scene_object3d import Object3D
 
-register_vue_component('scene', Path(__file__).parent / 'scene.js')
-register_library('three', Path(__file__).parent / 'lib' / 'three' / 'three.module.js', expose=True)
-register_library('CSS2DRenderer', Path(__file__).parent / 'lib' / 'three' / 'modules' / 'CSS2DRenderer.js', expose=True)
-register_library('CSS3DRenderer', Path(__file__).parent / 'lib' / 'three' / 'modules' / 'CSS3DRenderer.js', expose=True)
-register_library('OrbitControls', Path(__file__).parent / 'lib' / 'three' / 'modules' / 'OrbitControls.js', expose=True)
-register_library('STLLoader', Path(__file__).parent / 'lib' / 'three' / 'modules' / 'STLLoader.js', expose=True)
-register_library('tween', Path(__file__).parent / 'lib' / 'tween' / 'tween.umd.js')
-
 
 @dataclass(**KWONLY_SLOTS)
 class SceneCamera:
@@ -36,7 +26,16 @@ class SceneObject:
     id: str = 'scene'
 
 
-class Scene(Element):
+class Scene(Element,
+            component='scene.js',
+            libraries=['lib/tween/tween.umd.js'],
+            exposed_libraries=[
+                'lib/three/three.module.js',
+                'lib/three/modules/CSS2DRenderer.js',
+                'lib/three/modules/CSS3DRenderer.js',
+                'lib/three/modules/OrbitControls.js',
+                'lib/three/modules/STLLoader.js',
+            ]):
     from .scene_objects import Box as box
     from .scene_objects import Curve as curve
     from .scene_objects import Cylinder as cylinder
@@ -71,7 +70,7 @@ class Scene(Element):
         :param grid: whether to display a grid
         :param on_click: callback to execute when a 3d object is clicked
         """
-        super().__init__('scene')
+        super().__init__()
         self._props['width'] = width
         self._props['height'] = height
         self._props['grid'] = grid
@@ -82,13 +81,6 @@ class Scene(Element):
         self.is_initialized = False
         self.on('init', self.handle_init)
         self.on('click3d', self.handle_click)
-        self.use_component('scene')
-        self.use_library('three')
-        self.use_library('CSS2DRenderer')
-        self.use_library('CSS3DRenderer')
-        self.use_library('OrbitControls')
-        self.use_library('STLLoader')
-        self.use_library('tween')
 
     def handle_init(self, e: GenericEventArguments) -> None:
         self.is_initialized = True

+ 2 - 7
nicegui/elements/select.py

@@ -1,17 +1,13 @@
 import re
 from copy import deepcopy
-from pathlib import Path
 from typing import Any, Callable, Dict, List, Optional, Union
 
-from ..dependencies import register_vue_component
 from ..events import GenericEventArguments
 from .choice_element import ChoiceElement
 from .mixins.disableable_element import DisableableElement
 
-register_vue_component('select', Path(__file__).parent / 'select.js')
 
-
-class Select(ChoiceElement, DisableableElement):
+class Select(ChoiceElement, DisableableElement, component='select.js'):
 
     def __init__(self,
                  options: Union[List, Dict], *,
@@ -40,8 +36,7 @@ class Select(ChoiceElement, DisableableElement):
                 value = []
             elif not isinstance(value, list):
                 value = [value]
-        super().__init__(tag='select', options=options, value=value, on_change=on_change)
-        self.use_component('select')
+        super().__init__(options=options, value=value, on_change=on_change)
         if label is not None:
             self._props['label'] = label
         if with_input:

+ 2 - 8
nicegui/elements/table.py

@@ -1,17 +1,13 @@
-from pathlib import Path
 from typing import Any, Callable, Dict, List, Optional
 
 from typing_extensions import Literal
 
-from ..dependencies import register_vue_component
 from ..element import Element
 from ..events import GenericEventArguments, TableSelectionEventArguments, handle_event
 from .mixins.filter_element import FilterElement
 
-register_vue_component('nicegui-table', Path(__file__).parent / 'table.js')
 
-
-class Table(FilterElement):
+class Table(FilterElement, component='table.js'):
 
     def __init__(self,
                  columns: List[Dict],
@@ -36,7 +32,7 @@ class Table(FilterElement):
 
         If selection is 'single' or 'multiple', then a `selected` property is accessible containing the selected rows.
         """
-        super().__init__(tag='nicegui-table')
+        super().__init__()
 
         self.rows = rows
         self.row_key = row_key
@@ -63,8 +59,6 @@ class Table(FilterElement):
             handle_event(on_select, arguments)
         self.on('selection', handle_selection, ['added', 'rows', 'keys'])
 
-        self.use_component('nicegui-table')
-
     def add_rows(self, *rows: Dict) -> None:
         """Add rows to the table."""
         self.rows.extend(rows)

+ 1 - 1
nicegui/elements/textarea.py

@@ -3,7 +3,7 @@ from typing import Any, Callable, Dict, Optional
 from .input import Input
 
 
-class Textarea(Input):
+class Textarea(Input, component='input.js'):
 
     def __init__(self,
                  label: Optional[str] = None, *,

+ 2 - 7
nicegui/elements/upload.py

@@ -1,18 +1,14 @@
-from pathlib import Path
 from typing import Any, Callable, Dict, Optional
 
 from fastapi import Request
 from starlette.datastructures import UploadFile
 
-from ..dependencies import register_vue_component
 from ..events import EventArguments, UploadEventArguments, handle_event
 from ..nicegui import app
 from .mixins.disableable_element import DisableableElement
 
-register_vue_component('upload', Path(__file__).parent / 'upload.js')
 
-
-class Upload(DisableableElement):
+class Upload(DisableableElement, component='upload.js'):
 
     def __init__(self, *,
                  multiple: bool = False,
@@ -37,8 +33,7 @@ class Upload(DisableableElement):
         :param label: label for the uploader (default: `''`)
         :param auto_upload: automatically upload files when they are selected (default: `False`)
         """
-        super().__init__(tag='upload')
-        self.use_component('upload')
+        super().__init__()
         self._props['multiple'] = multiple
         self._props['label'] = label
         self._props['auto-upload'] = auto_upload

+ 2 - 6
nicegui/elements/video.py

@@ -3,13 +3,10 @@ from pathlib import Path
 from typing import Union
 
 from .. import globals
-from ..dependencies import register_vue_component
 from ..element import Element
 
-register_vue_component('video', Path(__file__).parent / 'video.js')
 
-
-class Video(Element):
+class Video(Element, component='video.js'):
 
     def __init__(self, src: Union[str, Path], *,
                  controls: bool = True,
@@ -29,7 +26,7 @@ class Video(Element):
         See `here <https://developer.mozilla.org/en-US/docs/Web/HTML/Element/video#events>`_
         for a list of events you can subscribe to using the generic event subscription `on()`.
         """
-        super().__init__('video')
+        super().__init__()
         if Path(src).is_file():
             src = globals.app.add_media_file(local_file=src)
         self._props['src'] = src
@@ -37,7 +34,6 @@ class Video(Element):
         self._props['autoplay'] = autoplay
         self._props['muted'] = muted
         self._props['loop'] = loop
-        self.use_component('video')
 
         if type:
             url = f'https://github.com/zauberzeug/nicegui/pull/624'

+ 5 - 7
nicegui/functions/refreshable.py

@@ -1,16 +1,12 @@
 from dataclasses import dataclass
-from pathlib import Path
 from typing import Any, Awaitable, Callable, Dict, List, Tuple, Union
 
 from typing_extensions import Self
 
 from .. import background_tasks, globals
-from ..dependencies import register_vue_component
 from ..element import Element
 from ..helpers import KWONLY_SLOTS, is_coroutine_function
 
-register_vue_component('refreshable', Path(__file__).parent / 'refreshable.js')
-
 
 @dataclass(**KWONLY_SLOTS)
 class RefreshableTarget:
@@ -37,6 +33,10 @@ class RefreshableTarget:
             return None  # required by mypy
 
 
+class RefreshableContainer(Element, component='refreshable.js'):
+    pass
+
+
 class refreshable:
 
     def __init__(self, func: Callable[..., Any]) -> None:
@@ -55,9 +55,7 @@ class refreshable:
 
     def __call__(self, *args: Any, **kwargs: Any) -> Union[None, Awaitable]:
         self.prune()
-        container = Element('refreshable')
-        container.use_component('refreshable')
-        target = RefreshableTarget(container=container, instance=self.instance, args=args, kwargs=kwargs)
+        target = RefreshableTarget(container=RefreshableContainer(), instance=self.instance, args=args, kwargs=kwargs)
         self.targets.append(target)
         return target.run(self.func)
 

+ 14 - 15
nicegui/nicegui.py

@@ -44,21 +44,20 @@ def index(request: Request) -> Response:
     return globals.index_client.build_response(request)
 
 
-@app.get(f'/_nicegui/{__version__}' + '/library/{name}/{file}')
-def get_dependencies(name: str, file: str):
-    if name in libraries and libraries[name]['path'].exists():
-        filepath = Path(libraries[name]['path']).parent / file
-        if filepath.exists() and not filepath.is_dir():
-            return FileResponse(filepath, media_type='text/javascript')
-        return FileResponse(libraries[name]['path'], media_type='text/javascript')
-    raise HTTPException(status_code=404, detail=f'dependency "{name}" not found')
-
-
-@app.get(f'/_nicegui/{__version__}' + '/components/{name}')
-def get_components(name: str):
-    if name in js_components and js_components[name]['path'].exists():
-        return FileResponse(js_components[name]['path'], media_type='text/javascript')
-    raise HTTPException(status_code=404, detail=f'library "{name}" not found')
+@app.get(f'/_nicegui/{__version__}' + '/libraries/{key:path}')
+def get_library(key: str) -> FileResponse:
+    if key in libraries and libraries[key].path.exists():
+        headers = {'Cache-Control': 'public, max-age=3600'}
+        return FileResponse(libraries[key].path, media_type='text/javascript', headers=headers)
+    raise HTTPException(status_code=404, detail=f'library "{key}" not found')
+
+
+@app.get(f'/_nicegui/{__version__}' + '/components/{key:path}')
+def get_component(key: str) -> FileResponse:
+    if key in js_components and js_components[key].path.exists():
+        headers = {'Cache-Control': 'public, max-age=3600'}
+        return FileResponse(js_components[key].path, media_type='text/javascript', headers=headers)
+    raise HTTPException(status_code=404, detail=f'component "{key}" not found')
 
 
 @app.on_event('startup')

+ 68 - 42
nicegui/templates/index.html

@@ -19,7 +19,9 @@
     <script src="{{ prefix | safe }}/_nicegui/{{version}}/static/vue.global.prod.js"></script>
     <script src="{{ prefix | safe }}/_nicegui/{{version}}/static/quasar.umd.prod.js"></script>
     <script src="{{ prefix | safe }}/_nicegui/{{version}}/static/quasar.{{ language }}.umd.prod.js"></script>
-    {{ import_maps | safe }}
+    <script type="importmap">
+      {"imports": {{ imports | safe }}}
+    </script>
 
     <!-- NOTE: force Prettier to keep the line break -->
     {{ body_html | safe }}
@@ -103,8 +105,8 @@
         }
 
         // @todo: Try avoid this with better handling of initial page load.
-        element['components'].forEach((component) => loaded_components.add(component));
-        element['libraries'].forEach((library) => loaded_libraries.add(library));
+        if (element.component) loaded_components.add(element.component.name);
+        element.libraries.forEach((library) => loaded_libraries.add(library.name));
 
         const props = {
           id: 'c' + element.id,
@@ -151,7 +153,7 @@
                 props: { props: { type: Object, default: {} } },
                 template: data.template,
               }, {
-                props: props
+                props: props,
               }));
             }
             const children = data.ids.map(id => renderRecursively(elements, id));
@@ -188,17 +190,19 @@
       }
 
       async function loadDependencies(element) {
-        for (const name of element['libraries']) {
+        if (element.component) {
+          const {name, key, tag} = element.component;
+          if (!loaded_components.has(name) && !key.endsWith('.vue')) {
+            const component = await import(`{{ prefix | safe }}/_nicegui/{{version}}/components/${key}`);
+            app = app.component(tag, component.default);
+            loaded_components.add(name);
+          }
+        }
+        for (const {name, key} of element.libraries) {
           if (loaded_libraries.has(name)) continue;
-          await import(`{{ prefix | safe }}/_nicegui/{{version}}/library/${name}/include`);
+          await import(`{{ prefix | safe }}/_nicegui/{{version}}/libraries/${key}`);
           loaded_libraries.add(name);
         }
-        for (const name of element['components']) {
-          if (loaded_components.has(name)) continue;
-          const component = (await import(`{{ prefix | safe }}/_nicegui/{{version}}/components/${name}`)).default;
-          app = app.component(name, component);
-          loaded_components.add(name);
-        }
       }
 
       let app = Vue.createApp({
@@ -218,37 +222,59 @@
           const transports = ['websocket', 'polling'];
           window.path_prefix = "{{ prefix | safe }}";
           window.socket = io(url, { path: "{{ prefix | safe }}/_nicegui_ws/socket.io", query, extraHeaders, transports });
-          window.socket.on("connect", () => {
-            window.socket.emit("handshake", (ok) => {
-              if (!ok) window.location.reload();
-              document.getElementById('popup').style.opacity = 0;
+          const messageHandlers = {
+            connect: () => {
+              window.socket.emit("handshake", (ok) => {
+                if (!ok) window.location.reload();
+                document.getElementById('popup').style.opacity = 0;
+              });
+            },
+            connect_error: (err) => {
+              if (err.message == 'timeout') window.location.reload(); // see https://github.com/zauberzeug/nicegui/issues/198
+            },
+            disconnect: () => {
+              document.getElementById('popup').style.opacity = 1;
+            },
+            update: async (msg) => {
+              for (const [id, element] of Object.entries(msg)) {
+                await loadDependencies(element);
+                this.elements[element.id] = element;
+              }
+            },
+            run_method: (msg) => {
+              const element = getElement(msg.id);
+              if (element === null || element === undefined) return;
+              if (msg.name in element) {
+                element[msg.name](...msg.args);
+              } else {
+                element.$refs.qRef[msg.name](...msg.args);
+              }
+            },
+            run_javascript: (msg) => runJavascript(msg['code'], msg['request_id']),
+            open: (msg) => (location.href = msg.startsWith('/') ? "{{ prefix | safe }}" + msg : msg),
+            download: (msg) => download(msg.url, msg.filename),
+            notify: (msg) => Quasar.Notify.create(msg),
+          };
+          const socketMessageQueue = [];
+          let isProcessingSocketMessage = false;
+          for (const [event, handler] of Object.entries(messageHandlers)) {
+            window.socket.on(event, async (...args) => {
+              socketMessageQueue.push(() => handler(...args));
+              if (!isProcessingSocketMessage) {
+                while (socketMessageQueue.length > 0) {
+                  const handler = socketMessageQueue.shift()
+                  isProcessingSocketMessage = true;
+                  try {
+                    await handler();
+                  }
+                  catch (e) {
+                    console.error(e);
+                  }
+                  isProcessingSocketMessage = false;
+                }
+              }
             });
-          });
-          window.socket.on("connect_error", (err) => {
-            if (err.message == 'timeout') window.location.reload(); // see https://github.com/zauberzeug/nicegui/issues/198
-          });
-          window.socket.on("disconnect", () => {
-            document.getElementById('popup').style.opacity = 1;
-          });
-          window.socket.on("update", async (msg) => {
-            for (const [id, element] of Object.entries(msg)) {
-              await loadDependencies(element);
-              this.elements[element.id] = element;
-            }
-          });
-          window.socket.on("run_method", (msg) => {
-            const element = getElement(msg.id);
-            if (element === null || element === undefined) return;
-            if (msg.name in element) {
-              element[msg.name](...msg.args);
-            } else {
-              element.$refs.qRef[msg.name](...msg.args);
-            }
-          });
-          window.socket.on("run_javascript", (msg) => runJavascript(msg['code'], msg['request_id']));
-          window.socket.on("open", (msg) => (location.href = msg.startsWith('/') ? "{{ prefix | safe }}" + msg : msg));
-          window.socket.on("download", (msg) => download(msg.url, msg.filename));
-          window.socket.on("notify", (msg) => Quasar.Notify.create(msg));
+          }
         },
       }).use(Quasar, {
         config: {

+ 18 - 0
tests/test_aggrid.py

@@ -1,4 +1,5 @@
 from selenium.webdriver.common.action_chains import ActionChains
+from selenium.webdriver.common.by import By
 from selenium.webdriver.common.keys import Keys
 
 from nicegui import ui
@@ -142,3 +143,20 @@ def test_create_from_pandas(screen: Screen):
     screen.should_contain('Bob')
     screen.should_contain('18')
     screen.should_contain('21')
+
+
+def test_create_dynamically(screen: Screen):
+    ui.button('Create', on_click=lambda: ui.aggrid({'columnDefs': [{'field': 'name'}], 'rowData': [{'name': 'Alice'}]}))
+
+    screen.open('/')
+    screen.click('Create')
+    screen.should_contain('Alice')
+
+
+def test_api_method_after_creation(screen: Screen):
+    options = {'columnDefs': [{'field': 'name'}], 'rowData': [{'name': 'Alice'}]}
+    ui.button('Create', on_click=lambda: ui.aggrid(options).call_api_method('selectAll'))
+
+    screen.open('/')
+    screen.click('Create')
+    assert screen.selenium.find_element(By.CLASS_NAME, 'ag-row-selected')

+ 15 - 6
tests/test_chart.py

@@ -73,18 +73,19 @@ def test_removing_chart_series(screen: Screen):
     assert len(screen.selenium.find_elements(By.CSS_SELECTOR, '.highcharts-point')) == 3
 
 
-def test_extra(screen: Screen):
-    ui.chart({'chart': {'type': 'solidgauge'}}, extras=['solid-gauge'])
+def test_missing_extra(screen: Screen):
+    # NOTE: This test does not work after test_extra() has been run, because conftest won't reset libraries correctly.
+    ui.chart({'chart': {'type': 'solidgauge'}})
 
     screen.open('/')
-    assert screen.selenium.find_elements(By.CSS_SELECTOR, '.highcharts-pane')
+    assert not screen.selenium.find_elements(By.CSS_SELECTOR, '.highcharts-pane')
 
 
-def test_missing_extra(screen: Screen):
-    ui.chart({'chart': {'type': 'solidgauge'}})
+def test_extra(screen: Screen):
+    ui.chart({'chart': {'type': 'solidgauge'}}, extras=['solid-gauge'])
 
     screen.open('/')
-    assert not screen.selenium.find_elements(By.CSS_SELECTOR, '.highcharts-pane')
+    assert screen.selenium.find_elements(By.CSS_SELECTOR, '.highcharts-pane')
 
 
 def test_stock_chart(screen: Screen):
@@ -131,3 +132,11 @@ def test_stock_chart(screen: Screen):
     screen.wait(0.5)
     screen.should_not_contain('alice')
     screen.should_not_contain('bob')
+
+
+def test_create_dynamically(screen: Screen):
+    ui.button('Create', on_click=lambda: ui.chart({}))
+
+    screen.open('/')
+    screen.click('Create')
+    screen.should_contain('Chart title')

+ 9 - 0
tests/test_mermaid.py

@@ -53,5 +53,14 @@ def test_replace_mermaid(screen: Screen):
     screen.open('/')
     screen.should_contain('Node_A')
     screen.click('Replace')
+    screen.wait(0.5)
     screen.should_contain('Node_B')
     screen.should_not_contain('Node_A')
+
+
+def test_create_dynamically(screen: Screen):
+    ui.button('Create', on_click=lambda: ui.mermaid('graph LR; Node'))
+
+    screen.open('/')
+    screen.click('Create')
+    screen.should_contain('Node')

+ 8 - 0
tests/test_plotly.py

@@ -40,3 +40,11 @@ def test_replace_plotly(screen: Screen):
     screen.click('Replace')
     screen.wait(0.5)
     assert screen.find_by_tag('text').text == 'B'
+
+
+def test_create_dynamically(screen: Screen):
+    ui.button('Create', on_click=lambda: ui.plotly(go.Figure(go.Scatter(x=[], y=[]))))
+
+    screen.open('/')
+    screen.click('Create')
+    assert screen.find_by_tag('svg')

+ 8 - 0
tests/test_scene.py

@@ -93,3 +93,11 @@ def test_replace_scene(screen: Screen):
     screen.click('Replace scene')
     screen.wait(0.5)
     assert screen.selenium.execute_script(f'return scene_c{scene.id}.children[4].name') == 'box'
+
+
+def test_create_dynamically(screen: Screen):
+    ui.button('Create', on_click=lambda: ui.scene())
+
+    screen.open('/')
+    screen.click('Create')
+    assert screen.find_by_tag('canvas')

+ 2 - 7
website/intersection_observer.py

@@ -1,21 +1,16 @@
-from pathlib import Path
 from typing import Callable
 
-from nicegui.dependencies import register_vue_component
 from nicegui.element import Element
 from nicegui.events import EventArguments, handle_event
 
-register_vue_component('intersection_observer', Path(__file__).parent / 'intersection_observer.js')
 
-
-class IntersectionObserver(Element):
+class IntersectionObserver(Element, component='intersection_observer.js'):
 
     def __init__(self, *, on_intersection: Callable) -> None:
-        super().__init__('intersection_observer')
+        super().__init__()
         self.on_intersection = on_intersection
         self.active = True
         self.on('intersection', self.handle_intersection, [])
-        self.use_component('intersection_observer')
 
     def handle_intersection(self, _) -> None:
         self.run_method('stop')