|
| 1 | +# type: ignore |
| 2 | +import asyncio |
| 3 | +import logging |
| 4 | + |
| 5 | +from jsonpointer import set_pointer |
| 6 | +from pyodide.ffi.wrappers import add_event_listener |
| 7 | +from pyscript.js_modules import morphdom |
| 8 | + |
| 9 | +import js |
| 10 | +from reactpy.core.layout import Layout |
| 11 | + |
| 12 | + |
| 13 | +class ReactPyLayoutHandler: |
| 14 | + """Encapsulate the entire layout handler with a class to prevent overlapping |
| 15 | + variable names between user code. |
| 16 | +
|
| 17 | + This code is designed to be run directly by PyScript, and is not intended to be run |
| 18 | + in a normal Python environment. |
| 19 | + """ |
| 20 | + |
| 21 | + def __init__(self, uuid): |
| 22 | + self.uuid = uuid |
| 23 | + self.running_tasks = set() |
| 24 | + |
| 25 | + @staticmethod |
| 26 | + def update_model(update, root_model): |
| 27 | + """Apply an update ReactPy's internal DOM model.""" |
| 28 | + if update["path"]: |
| 29 | + set_pointer(root_model, update["path"], update["model"]) |
| 30 | + else: |
| 31 | + root_model.update(update["model"]) |
| 32 | + |
| 33 | + def render_html(self, layout, model): |
| 34 | + """Submit ReactPy's internal DOM model into the HTML DOM.""" |
| 35 | + # Create a new container to render the layout into |
| 36 | + container = js.document.getElementById(f"pyscript-{self.uuid}") |
| 37 | + temp_root_container = container.cloneNode(False) |
| 38 | + self.build_element_tree(layout, temp_root_container, model) |
| 39 | + |
| 40 | + # Use morphdom to update the DOM |
| 41 | + morphdom.default(container, temp_root_container) |
| 42 | + |
| 43 | + # Remove the cloned container to prevent memory leaks |
| 44 | + temp_root_container.remove() |
| 45 | + |
| 46 | + def build_element_tree(self, layout, parent, model): |
| 47 | + """Recursively build an element tree, starting from the root component.""" |
| 48 | + # If the model is a string, add it as a text node |
| 49 | + if isinstance(model, str): |
| 50 | + parent.appendChild(js.document.createTextNode(model)) |
| 51 | + |
| 52 | + # If the model is a VdomDict, construct an element |
| 53 | + elif isinstance(model, dict): |
| 54 | + # If the model is a fragment, build the children |
| 55 | + if not model["tagName"]: |
| 56 | + for child in model.get("children", []): |
| 57 | + self.build_element_tree(layout, parent, child) |
| 58 | + return |
| 59 | + |
| 60 | + # Otherwise, get the VdomDict attributes |
| 61 | + tag = model["tagName"] |
| 62 | + attributes = model.get("attributes", {}) |
| 63 | + children = model.get("children", []) |
| 64 | + element = js.document.createElement(tag) |
| 65 | + |
| 66 | + # Set the element's HTML attributes |
| 67 | + for key, value in attributes.items(): |
| 68 | + if key == "style": |
| 69 | + for style_key, style_value in value.items(): |
| 70 | + setattr(element.style, style_key, style_value) |
| 71 | + elif key == "className": |
| 72 | + element.className = value |
| 73 | + else: |
| 74 | + element.setAttribute(key, value) |
| 75 | + |
| 76 | + # Add event handlers to the element |
| 77 | + for event_name, event_handler_model in model.get( |
| 78 | + "eventHandlers", {} |
| 79 | + ).items(): |
| 80 | + self.create_event_handler( |
| 81 | + layout, element, event_name, event_handler_model |
| 82 | + ) |
| 83 | + |
| 84 | + # Recursively build the children |
| 85 | + for child in children: |
| 86 | + self.build_element_tree(layout, element, child) |
| 87 | + |
| 88 | + # Append the element to the parent |
| 89 | + parent.appendChild(element) |
| 90 | + |
| 91 | + # Unknown data type provided |
| 92 | + else: |
| 93 | + msg = f"Unknown model type: {type(model)}" |
| 94 | + raise TypeError(msg) |
| 95 | + |
| 96 | + def create_event_handler(self, layout, element, event_name, event_handler_model): |
| 97 | + """Create an event handler for an element. This function is used as an |
| 98 | + adapter between ReactPy and browser events.""" |
| 99 | + target = event_handler_model["target"] |
| 100 | + |
| 101 | + def event_handler(*args): |
| 102 | + # When the event is triggered, deliver the event to the `Layout` within a background task |
| 103 | + task = asyncio.create_task( |
| 104 | + layout.deliver({"type": "layout-event", "target": target, "data": args}) |
| 105 | + ) |
| 106 | + # Store the task to prevent automatic garbage collection from killing it |
| 107 | + self.running_tasks.add(task) |
| 108 | + task.add_done_callback(self.running_tasks.remove) |
| 109 | + |
| 110 | + add_event_listener(element, event_name, event_handler) |
| 111 | + |
| 112 | + @staticmethod |
| 113 | + def delete_old_workspaces(): |
| 114 | + """To prevent memory leaks, we must delete all user generated Python code when |
| 115 | + it is no longer in use (removed from the page). To do this, we compare what |
| 116 | + UUIDs exist on the DOM, versus what UUIDs exist within the PyScript global |
| 117 | + interpreter.""" |
| 118 | + # Find all PyScript workspaces that are still on the page |
| 119 | + dom_workspaces = js.document.querySelectorAll(".pyscript") |
| 120 | + dom_uuids = {element.dataset.uuid for element in dom_workspaces} |
| 121 | + python_uuids = { |
| 122 | + value.split("_")[-1] |
| 123 | + for value in globals() |
| 124 | + if value.startswith("user_workspace_") |
| 125 | + } |
| 126 | + |
| 127 | + # Delete any workspaces that are no longer in use |
| 128 | + for uuid in python_uuids - dom_uuids: |
| 129 | + task_name = f"task_{uuid}" |
| 130 | + if task_name in globals(): |
| 131 | + task: asyncio.Task = globals()[task_name] |
| 132 | + task.cancel() |
| 133 | + del globals()[task_name] |
| 134 | + else: |
| 135 | + logging.error("Could not auto delete PyScript task %s", task_name) |
| 136 | + |
| 137 | + workspace_name = f"user_workspace_{uuid}" |
| 138 | + if workspace_name in globals(): |
| 139 | + del globals()[workspace_name] |
| 140 | + else: |
| 141 | + logging.error( |
| 142 | + "Could not auto delete PyScript workspace %s", workspace_name |
| 143 | + ) |
| 144 | + |
| 145 | + async def run(self, workspace_function): |
| 146 | + """Run the layout handler. This function is main executor for all user generated code.""" |
| 147 | + self.delete_old_workspaces() |
| 148 | + root_model: dict = {} |
| 149 | + |
| 150 | + async with Layout(workspace_function()) as root_layout: |
| 151 | + while True: |
| 152 | + update = await root_layout.render() |
| 153 | + self.update_model(update, root_model) |
| 154 | + self.render_html(root_layout, root_model) |
0 commit comments