diff --git a/docs/advanced_onboarding/configuration.md b/docs/advanced_onboarding/configuration.md index d538c8b7c75..0355023e081 100644 --- a/docs/advanced_onboarding/configuration.md +++ b/docs/advanced_onboarding/configuration.md @@ -44,6 +44,10 @@ uv run reflex run --frontend-port 3001 See the [CLI reference](/docs/api-reference/cli) for all the arguments available. +## Frontend Inspector + +For mapping rendered DOM nodes back to the Python source that produced them, see the [Frontend Inspector](/docs/advanced_onboarding/frontend_inspector) page. Enable it by adding `rx.plugins.FrontendInspectorPlugin()` to `plugins` in your `rxconfig.py`. + ## Customizable App Data Directory The `REFLEX_DIR` environment variable can be set, which allows users to set the location where Reflex writes helper tools like Bun and NodeJS. diff --git a/docs/advanced_onboarding/frontend_inspector.md b/docs/advanced_onboarding/frontend_inspector.md new file mode 100644 index 00000000000..d223788a104 --- /dev/null +++ b/docs/advanced_onboarding/frontend_inspector.md @@ -0,0 +1,90 @@ +```python exec +import reflex as rx +``` + +# Frontend Inspector + +The frontend inspector maps rendered DOM nodes back to the Python source line that created them. Hover an element in the browser, see which `Component.create(...)` call produced it, and click to open that line in your editor. + +It is a development-only tool. The plugin is a no-op under `REFLEX_ENV_MODE=prod`, so the same `rxconfig.py` works for both dev and prod runs. + +## Enable + +Add `FrontendInspectorPlugin` to `plugins` in your `rxconfig.py`: + +```python +import reflex as rx + +config = rx.Config( + app_name="my_app", + plugins=[rx.plugins.FrontendInspectorPlugin()], +) +``` + +Run your app in dev mode (`uv run reflex run`). The inspector loads automatically; the `launch-editor` dev dependency is installed during the same compile pass. + +## Usage + +Three modes: + +- **Hover with `alt` held** — show the overlay while inspecting. The overlay disappears as soon as you release `alt`. +- **`alt+x`** — toggle persistent mode. The overlay stays on; the small `rx-inspect` button in the bottom-right corner reflects the state. +- **`alt`+click** — open the source file at the captured line in your editor. The modifier is required at click time so re-focusing the browser window doesn't hijack normal clicks. + +`Esc` exits persistent mode. Pressing `c` while hovering copies `path:line:column` to the clipboard. + +## Configuration + +```python +config = rx.Config( + app_name="my_app", + plugins=[ + rx.plugins.FrontendInspectorPlugin( + # Custom shortcut. Modifier aliases like cmd / option are accepted. + shortcut="ctrl+shift+i", + # Optional: override the editor invocation. Empty falls back to + # $REFLEX_EDITOR / $VISUAL / $EDITOR / launch-editor's auto-detection. + editor="code -g", + ), + ], +) +``` + +| Argument | Default | Notes | +| --- | --- | --- | +| `shortcut` | `"alt+x"` | Modifiers: `alt`, `ctrl`, `meta` (`cmd`/`super`/`win`), `shift`. | +| `editor` | `""` | Forwarded to [`launch-editor`](https://github.com/yyx990803/launch-editor). | + +## Production safety + +The plugin's hooks all return empty when `REFLEX_ENV_MODE=prod`, so prod builds emit no inspector wiring: + +- `uv run reflex run --env prod` +- `uv run reflex export --env prod` +- Any deploy that sets `REFLEX_ENV_MODE=prod`. + +The gate is re-evaluated at every emission site, so the same `rxconfig.py` works in both dev and prod without further changes. + +## What it does and does not do + +It does: + +- Add a small `data-rx=""` attribute to every component that has a non-Fragment tag. +- Emit `.web/public/__reflex/source-map.json` mapping ids to `(file, line, column, component)`. +- Mount a Vite dev-server middleware at `/__open-in-editor` that calls `launch-editor`. + +It does not: + +- Inspect React state or props at runtime — it is a source-mapping tool, not a React DevTools replacement. +- Run in production. The Vite plugin is registered with `apply: 'serve'`, so even if a stray asset slipped through, prod builds would not load it. +- Modify your source code. The inspector stores a private id on each component that gets rendered out as a `data-rx` attribute; your `rxconfig.py` and component files are untouched. + +## Programmatic toggle + +When the inspector is loaded, `window.__REFLEX_INSPECTOR__` exposes the runtime API for ad-hoc debugging in the browser console: + +```js +window.__REFLEX_INSPECTOR__.enable(); +window.__REFLEX_INSPECTOR__.toggle(); +window.__REFLEX_INSPECTOR__.sourceCount(); // number of mapped ids +``` diff --git a/docs/app/reflex_docs/templates/docpage/sidebar/sidebar_items/learn.py b/docs/app/reflex_docs/templates/docpage/sidebar/sidebar_items/learn.py index 0f4d652eb5d..a38ad2116d8 100644 --- a/docs/app/reflex_docs/templates/docpage/sidebar/sidebar_items/learn.py +++ b/docs/app/reflex_docs/templates/docpage/sidebar/sidebar_items/learn.py @@ -30,6 +30,7 @@ def get_sidebar_items_learn(): advanced_onboarding.how_reflex_works, advanced_onboarding.configuration, advanced_onboarding.code_structure, + advanced_onboarding.frontend_inspector, ], ), ] diff --git a/docs/app/rxconfig.py b/docs/app/rxconfig.py index 11e5f8b02d2..7fa1671c4f5 100644 --- a/docs/app/rxconfig.py +++ b/docs/app/rxconfig.py @@ -12,6 +12,7 @@ plugins=[ rx.plugins.TailwindV4Plugin(), rx.plugins.SitemapPlugin(trailing_slash="always"), + rx.plugins.FrontendInspectorPlugin(), AgentFilesPlugin(), ], ) diff --git a/packages/reflex-base/src/reflex_base/assets/inspector/dev_server_middleware.js b/packages/reflex-base/src/reflex_base/assets/inspector/dev_server_middleware.js new file mode 100644 index 00000000000..a6286bf3e29 --- /dev/null +++ b/packages/reflex-base/src/reflex_base/assets/inspector/dev_server_middleware.js @@ -0,0 +1,38 @@ +/* Dev-server middleware that opens a file:line:column in the user's editor. + * + * Mounted by the inspector Vite plugin (and the equivalent Astro hook). + * The browser sends `GET /__open-in-editor?file=::`. + */ + +import launch from "launch-editor"; + +const reflexEditorMiddleware = (req, res, next) => { + if (!req.url || !req.url.includes("/__open-in-editor")) { + return next(); + } + let url; + try { + url = new URL(req.url, "http://localhost"); + } catch (err) { + res.statusCode = 400; + res.end("Invalid URL"); + return; + } + const file = url.searchParams.get("file"); + if (!file) { + res.statusCode = 400; + res.end("Missing 'file' query parameter"); + return; + } + const editor = process.env.REFLEX_EDITOR || undefined; + launch(file, editor, (fileName, errorMsg) => { + if (errorMsg) { + // eslint-disable-next-line no-console + console.error("[reflex-inspector] launch-editor:", errorMsg); + } + }); + res.statusCode = 204; + res.end(); +}; + +export default reflexEditorMiddleware; diff --git a/packages/reflex-base/src/reflex_base/assets/inspector/inspector.css b/packages/reflex-base/src/reflex_base/assets/inspector/inspector.css new file mode 100644 index 00000000000..317f3ebe8ec --- /dev/null +++ b/packages/reflex-base/src/reflex_base/assets/inspector/inspector.css @@ -0,0 +1,61 @@ +/* Reflex frontend inspector — dev-only overlay styles. */ + +#__reflex_inspector_outline { + position: fixed; + pointer-events: none; + z-index: 2147483646; + border: 2px solid #4f8cff; + background: rgba(79, 140, 255, 0.08); + border-radius: 2px; + transition: top 60ms linear, left 60ms linear, width 60ms linear, height 60ms linear; + display: none; +} + +#__reflex_inspector_label { + position: fixed; + pointer-events: none; + z-index: 2147483647; + background: #111827; + color: #f9fafb; + font: 500 12px/1.4 "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + padding: 4px 8px; + border-radius: 4px; + white-space: nowrap; + display: none; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); +} + +#__reflex_inspector_label .__reflex_inspector_chain { + color: #9ca3af; + margin-right: 6px; +} + +#__reflex_inspector_label .__reflex_inspector_file { + color: #93c5fd; +} + +#__reflex_inspector_toggle { + position: fixed; + bottom: 16px; + right: 16px; + z-index: 2147483647; + background: #111827; + color: #f9fafb; + border: 1px solid #374151; + border-radius: 999px; + font: 500 12px/1 "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + padding: 8px 12px; + cursor: pointer; + opacity: 0.65; + transition: opacity 120ms ease; +} + +#__reflex_inspector_toggle:hover { + opacity: 1; +} + +#__reflex_inspector_toggle[data-active="true"] { + background: #4f8cff; + border-color: #4f8cff; + opacity: 1; +} diff --git a/packages/reflex-base/src/reflex_base/assets/inspector/inspector.js b/packages/reflex-base/src/reflex_base/assets/inspector/inspector.js new file mode 100644 index 00000000000..8f7fbf656fc --- /dev/null +++ b/packages/reflex-base/src/reflex_base/assets/inspector/inspector.js @@ -0,0 +1,362 @@ +/* Reflex frontend inspector — dev-only browser script. + * + * Reads `data-rx` from rendered elements, looks them up in the source map + * served at /__reflex/source-map.json, draws a hover outline + label, and + * sends clicks to /__open-in-editor so the user's editor can jump to the + * Python call site. + * + * Loaded by the inspector Vite plugin (or Astro integration). The plugin + * also installs the editor middleware on the dev server. + */ + +const RX_DATA_ATTR = "data-rx"; +const SESSION_KEY = "__reflex_inspector_persistent"; +const OUTLINE_ID = "__reflex_inspector_outline"; +const LABEL_ID = "__reflex_inspector_label"; +const TOGGLE_ID = "__reflex_inspector_toggle"; + +const config = window.__REFLEX_INSPECTOR_CONFIG__ || {}; +const SOURCE_MAP_URL = config.sourceMapUrl || "/__reflex/source-map.json"; +const CSS_URL = config.cssUrl || "/__reflex/inspector.css"; +const EDITOR_URL = config.editorUrl || "/__open-in-editor"; +const shortcut = config.shortcut || { + key: "x", + alt: true, + ctrl: false, + meta: false, + shift: false, +}; + +let sourceMap = null; +let sourceMapPromise = null; +let persistent = false; +let modifierHeld = false; +let lastTarget = null; +let outlineEl = null; +let labelEl = null; +let toggleEl = null; +let focusGuardUntil = 0; +let sourceMapRequestId = 0; + +const clearSourceMap = () => { + sourceMap = null; + sourceMapPromise = null; + sourceMapRequestId += 1; +}; + +const loadSourceMap = (fresh = false) => { + if (fresh) clearSourceMap(); + if (sourceMap) return Promise.resolve(sourceMap); + if (sourceMapPromise) return sourceMapPromise; + const requestId = sourceMapRequestId; + sourceMapPromise = fetch(SOURCE_MAP_URL, { cache: "no-store" }) + .then((res) => (res.ok ? res.json() : {})) + .catch(() => ({})) + .then((map) => { + if (requestId === sourceMapRequestId) sourceMap = map; + return map; + }) + .finally(() => { + if (requestId === sourceMapRequestId) sourceMapPromise = null; + }); + return sourceMapPromise; +}; + +const ensureStylesheet = () => { + const existing = document.getElementById("__reflex_inspector_css"); + if (existing) return; + const link = document.createElement("link"); + link.id = "__reflex_inspector_css"; + link.rel = "stylesheet"; + link.href = CSS_URL; + document.head.appendChild(link); +}; + +const ensureOverlayElements = () => { + ensureStylesheet(); + if (!outlineEl) { + outlineEl = document.createElement("div"); + outlineEl.id = OUTLINE_ID; + document.body.appendChild(outlineEl); + } + if (!labelEl) { + labelEl = document.createElement("div"); + labelEl.id = LABEL_ID; + document.body.appendChild(labelEl); + } + if (!toggleEl) { + toggleEl = document.createElement("button"); + toggleEl.id = TOGGLE_ID; + toggleEl.type = "button"; + toggleEl.textContent = "rx-inspect"; + toggleEl.addEventListener("click", () => setPersistent(!persistent)); + document.body.appendChild(toggleEl); + } +}; + +const findInspected = (node) => { + while (node && node.nodeType === 1) { + if (node.hasAttribute(RX_DATA_ATTR)) return node; + node = node.parentElement; + } + return null; +}; + +const ancestorChain = (el, depth) => { + const chain = []; + let cur = el.parentElement; + while (cur && chain.length < depth) { + if (cur.hasAttribute(RX_DATA_ATTR)) { + const id = cur.getAttribute(RX_DATA_ATTR); + const info = sourceMap && sourceMap[id]; + if (info && info.component) chain.push(info.component); + } + cur = cur.parentElement; + } + return chain.reverse(); +}; + +const baseName = (file) => { + if (!file) return ""; + const i = Math.max(file.lastIndexOf("/"), file.lastIndexOf("\\")); + return i === -1 ? file : file.slice(i + 1); +}; + +const showOverlayFor = (el) => { + if (!el || !sourceMap) return; + const id = el.getAttribute(RX_DATA_ATTR); + const info = sourceMap[id]; + if (!info) { + hideOverlay(); + return; + } + ensureOverlayElements(); + const rect = el.getBoundingClientRect(); + outlineEl.style.display = "block"; + outlineEl.style.top = `${rect.top}px`; + outlineEl.style.left = `${rect.left}px`; + outlineEl.style.width = `${rect.width}px`; + outlineEl.style.height = `${rect.height}px`; + + const chain = ancestorChain(el, 3); + const breadcrumb = chain.length > 0 ? chain.join(" › ") + " › " : ""; + const file = baseName(info.file); + labelEl.innerHTML = ""; + if (breadcrumb) { + const span = document.createElement("span"); + span.className = "__reflex_inspector_chain"; + span.textContent = breadcrumb; + labelEl.appendChild(span); + } + const name = document.createElement("span"); + name.textContent = `${info.component} `; + labelEl.appendChild(name); + const fileSpan = document.createElement("span"); + fileSpan.className = "__reflex_inspector_file"; + fileSpan.textContent = `${file}:${info.line}`; + labelEl.appendChild(fileSpan); + + labelEl.style.display = "block"; + const labelTop = Math.max(0, rect.top - 24); + labelEl.style.top = `${labelTop}px`; + labelEl.style.left = `${rect.left}px`; +}; + +const hideOverlay = () => { + if (outlineEl) outlineEl.style.display = "none"; + if (labelEl) labelEl.style.display = "none"; +}; + +const openInEditor = async (info) => { + if (!info) return; + const params = new URLSearchParams({ + file: `${info.file}:${info.line}:${info.column || 1}`, + }); + try { + await fetch(`${EDITOR_URL}?${params.toString()}`, { method: "GET" }); + } catch (err) { + console.warn("[reflex-inspector] open-in-editor failed", err); + } +}; + +const copyPath = (info) => { + if (!info || !navigator.clipboard) return; + const text = `${info.file}:${info.line}:${info.column || 1}`; + navigator.clipboard.writeText(text).catch(() => undefined); +}; + +const expectedCode = (key) => { + // Layout-independent code for the configured shortcut key, used as a + // primary match: holding Alt on macOS/Linux can mutate event.key into + // dead/compose characters (e.g. Option+X → "≈"), which would otherwise + // make the second press silently miss. + if (key.length !== 1) return null; + if (key >= "a" && key <= "z") return `Key${key.toUpperCase()}`; + if (key >= "0" && key <= "9") return `Digit${key}`; + return null; +}; + +const matchesShortcut = (event) => { + if (event.repeat) return false; + const code = expectedCode(shortcut.key); + const keyMatch = event.key.toLowerCase() === shortcut.key; + const codeMatch = code !== null && event.code === code; + if (!keyMatch && !codeMatch) return false; + return ( + !!event.altKey === !!shortcut.alt && + !!event.ctrlKey === !!shortcut.ctrl && + !!event.metaKey === !!shortcut.meta && + !!event.shiftKey === !!shortcut.shift + ); +}; + +const hasConfiguredModifier = + !!shortcut.alt || !!shortcut.ctrl || !!shortcut.meta || !!shortcut.shift; + +const requiredModifiersHeld = (event) => + hasConfiguredModifier && + (!shortcut.alt || event.altKey) && + (!shortcut.ctrl || event.ctrlKey) && + (!shortcut.meta || event.metaKey) && + (!shortcut.shift || event.shiftKey); + +const isActive = () => persistent || modifierHeld; + +const updateToggle = () => { + if (!toggleEl) return; + toggleEl.dataset.active = String(persistent); +}; + +const setPersistent = async (on) => { + const wasPersistent = persistent; + persistent = !!on; + try { + sessionStorage.setItem(SESSION_KEY, persistent ? "1" : "0"); + } catch (_) { + // sessionStorage may be disabled — ignore. + } + if (persistent) await loadSourceMap(!wasPersistent); + updateToggle(); + if (!isActive()) hideOverlay(); +}; + +const onMouseMove = (event) => { + if (!isActive()) return; + if (Date.now() < focusGuardUntil) return; + if (isInspectorChrome(event.target)) { + hideOverlay(); + lastTarget = null; + return; + } + const target = findInspected(event.target); + lastTarget = target; + if (target) showOverlayFor(target); + else hideOverlay(); +}; + +const isInspectorChrome = (node) => { + while (node && node.nodeType === 1) { + if ( + node.id === TOGGLE_ID || + node.id === OUTLINE_ID || + node.id === LABEL_ID + ) { + return true; + } + node = node.parentElement; + } + return false; +}; + +const onClick = async (event) => { + if (!isActive()) return; + if (isInspectorChrome(event.target)) return; + // Require the configured modifier at click time so re-focusing the + // window or clicking through the page doesn't hijack normal clicks. + if (!requiredModifiersHeld(event)) return; + // Swallow the very first click after the window regains focus — the + // browser fires it as part of the focus transition and the user almost + // never means it as an inspector action. + if (Date.now() < focusGuardUntil) { + focusGuardUntil = 0; + return; + } + const target = findInspected(event.target); + if (!target) return; + event.preventDefault(); + event.stopPropagation(); + const map = await loadSourceMap(true); + const info = map && map[target.getAttribute(RX_DATA_ATTR)]; + openInEditor(info); +}; + +const onKeyDown = (event) => { + if (matchesShortcut(event)) { + event.stopPropagation(); + event.preventDefault(); + setPersistent(!persistent); + return; + } + if (requiredModifiersHeld(event) && !modifierHeld) { + modifierHeld = true; + loadSourceMap(true); + } + if (event.key === "Escape" && persistent) { + setPersistent(false); + } + if (event.key.toLowerCase() === "c" && lastTarget && isActive()) { + const info = sourceMap && sourceMap[lastTarget.getAttribute(RX_DATA_ATTR)]; + copyPath(info); + } +}; + +const onKeyUp = (event) => { + if (!requiredModifiersHeld(event) && modifierHeld) { + modifierHeld = false; + if (!persistent) hideOverlay(); + } +}; + +const installEvents = () => { + document.addEventListener("mousemove", onMouseMove, true); + document.addEventListener("click", onClick, true); + document.addEventListener("keydown", onKeyDown, true); + document.addEventListener("keyup", onKeyUp, true); + window.addEventListener("blur", () => { + modifierHeld = false; + hideOverlay(); + }); + window.addEventListener("focus", () => { + focusGuardUntil = Date.now() + 350; + }); +}; + +const init = () => { + if (window.__REFLEX_INSPECTOR__) return; + ensureOverlayElements(); + installEvents(); + + let initial = false; + try { + initial = sessionStorage.getItem(SESSION_KEY) === "1"; + } catch (_) { + initial = false; + } + if (window.location.search.includes("reflex-inspector")) initial = true; + + window.__REFLEX_INSPECTOR__ = { + enable: () => setPersistent(true), + disable: () => setPersistent(false), + toggle: () => setPersistent(!persistent), + isEnabled: () => persistent, + sourceCount: () => (sourceMap ? Object.keys(sourceMap).length : 0), + }; + + if (initial) setPersistent(true); +}; + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init, { once: true }); +} else { + init(); +} diff --git a/packages/reflex-base/src/reflex_base/components/component.py b/packages/reflex-base/src/reflex_base/components/component.py index 79b562ddc78..3cc09baf421 100644 --- a/packages/reflex-base/src/reflex_base/components/component.py +++ b/packages/reflex-base/src/reflex_base/components/component.py @@ -40,6 +40,9 @@ run_script, unwrap_var_annotation, ) +from reflex_base.inspector import DATA_ATTR as _INSPECTOR_DATA_ATTR +from reflex_base.inspector import capture as inspector_capture +from reflex_base.inspector import state as inspector_state from reflex_base.style import Style, format_as_emotion from reflex_base.utils import console, format, imports, types from reflex_base.utils.imports import ImportDict, ImportVar, ParsedImportDict @@ -758,6 +761,8 @@ class Component(BaseComponent, ABC): default=None, is_javascript_property=False ) + _inspector_id: int | None = field(default=None, is_javascript_property=False) + def add_imports(self) -> ImportDict | list[ImportDict]: """Add imports for the component. @@ -1124,6 +1129,9 @@ def _render(self, props: dict[str, Any] | None = None) -> Tag: for prop_to_exclude in self._exclude_props(): props.pop(prop_to_exclude, None) + if (cid := self._inspector_id) is not None and name and name != "Fragment": + props.setdefault(_INSPECTOR_DATA_ATTR, cid) + return tag.add_props(**props) @classmethod @@ -1191,6 +1199,12 @@ def create(cls: type[T], *children, **props) -> T: from reflex_components_core.base.bare import Bare from reflex_components_core.base.fragment import Fragment + cid = ( + inspector_capture.capture(cls.__name__) + if inspector_state.is_enabled() + else None + ) + # Filter out None props props = {key: value for key, value in props.items() if value is not None} @@ -1210,7 +1224,10 @@ def create(cls: type[T], *children, **props) -> T: for child in children ] - return cls._create(children_normalized, **props) + component = cls._create(children_normalized, **props) + if cid is not None: + component._inspector_id = cid + return component @classmethod def _create(cls: type[T], children: Sequence[BaseComponent], **props: Any) -> T: diff --git a/packages/reflex-base/src/reflex_base/inspector/__init__.py b/packages/reflex-base/src/reflex_base/inspector/__init__.py new file mode 100644 index 00000000000..bef65bad81e --- /dev/null +++ b/packages/reflex-base/src/reflex_base/inspector/__init__.py @@ -0,0 +1,46 @@ +"""Dev-only frontend inspector. + +The inspector maps rendered DOM nodes back to the Python ``Component`` call +site that produced them. Each piece is independently testable: + +- ``state`` toggles the global enabled flag (set by ``integration``). +- ``capture`` walks the call stack and records the user-code frame. +- ``emit`` writes the lookup table consumed by the browser script. +- ``shortcut`` parses the keyboard shortcut configured in ``rx.Config``. +- ``integration`` is the coordinator the rest of Reflex talks to. + +The browser-side counterpart lives under ``reflex_base/assets/inspector``. +The constants below are the shared contract between the Python compile-time +side and the JS runtime — pinned by ``tests/.../test_browser_contract.py``. +""" + +from __future__ import annotations + +from . import capture, shortcut, state + +DATA_ATTR = "data-rx" +PUBLIC_DIRNAME = "__reflex" +INSPECTOR_PLUGIN_FILE = "reflex-inspector-plugin.js" +SOURCE_MAP_FILENAME = "source-map.json" +SOURCE_MAP_URL = f"/{PUBLIC_DIRNAME}/{SOURCE_MAP_FILENAME}" +INSPECTOR_JS_URL = f"/{PUBLIC_DIRNAME}/inspector.js" +INSPECTOR_CSS_URL = f"/{PUBLIC_DIRNAME}/inspector.css" +EDITOR_URL = "/__open-in-editor" +WINDOW_CONFIG_KEY = "__REFLEX_INSPECTOR_CONFIG__" +SHORTCUT_CONFIG_KEY = "shortcut" + +__all__ = [ + "DATA_ATTR", + "EDITOR_URL", + "INSPECTOR_CSS_URL", + "INSPECTOR_JS_URL", + "INSPECTOR_PLUGIN_FILE", + "PUBLIC_DIRNAME", + "SHORTCUT_CONFIG_KEY", + "SOURCE_MAP_FILENAME", + "SOURCE_MAP_URL", + "WINDOW_CONFIG_KEY", + "capture", + "shortcut", + "state", +] diff --git a/packages/reflex-base/src/reflex_base/inspector/capture.py b/packages/reflex-base/src/reflex_base/inspector/capture.py new file mode 100644 index 00000000000..c19c3d0c386 --- /dev/null +++ b/packages/reflex-base/src/reflex_base/inspector/capture.py @@ -0,0 +1,116 @@ +"""Walk the Python call stack and record where a component was created.""" + +from __future__ import annotations + +import dataclasses +import itertools +import sys +from pathlib import Path + +from reflex_base.utils import frames + +from . import state + + +@dataclasses.dataclass(frozen=True, slots=True) +class SourceInfo: + """A user-code frame that constructed a component.""" + + file: str + line: int + column: int + component: str + + +_REGISTRY: dict[int, SourceInfo] = {} +_BY_INFO: dict[SourceInfo, int] = {} +_COUNTER = itertools.count(1) +_FRAMEWORK_ROOTS: tuple[Path, ...] = () +_RESOLVED_PATH_CACHE: dict[str, str] = {} + + +def _get_framework_roots() -> tuple[Path, ...]: + return _FRAMEWORK_ROOTS + + +_is_framework_frame = frames.make_framework_frame_predicate(_get_framework_roots) + + +def _ensure_framework_roots() -> None: + global _FRAMEWORK_ROOTS + if not _FRAMEWORK_ROOTS: + _FRAMEWORK_ROOTS = frames.discover_framework_roots() + + +def _resolve_filename(filename: str) -> str: + cached = _RESOLVED_PATH_CACHE.get(filename) + if cached is not None: + return cached + try: + resolved = str(Path(filename).resolve()) + except OSError: + resolved = filename + _RESOLVED_PATH_CACHE[filename] = resolved + return resolved + + +def capture(component_name: str) -> int | None: + """Walk the call stack and return a fresh inspector id for the user frame. + + Args: + component_name: ``cls.__name__`` of the component being constructed. + + Returns: + A new integer id when the inspector is enabled and a non-framework + frame is found; ``None`` otherwise (e.g. inspector disabled, only + framework code on the stack). + """ + if not state.is_enabled(): + return None + _ensure_framework_roots() + user_frame = frames.walk_to_first_non_framework_frame( + sys._getframe(1), _is_framework_frame + ) + try: + if user_frame is None: + return None + info = SourceInfo( + file=_resolve_filename(user_frame.f_code.co_filename), + line=user_frame.f_lineno, + column=1, + component=component_name, + ) + if (existing := _BY_INFO.get(info)) is not None: + return existing + cid = next(_COUNTER) + _REGISTRY[cid] = info + _BY_INFO[info] = cid + return cid + finally: + # Break the local frame reference so the captured frame's locals + # (which can transitively reference this function) become reclaimable. + del user_frame + + +def snapshot() -> dict[int, SourceInfo]: + """Return a copy of the current registry. + + Returns: + A shallow copy of the inspector id → ``SourceInfo`` mapping. + """ + return dict(_REGISTRY) + + +def reset() -> None: + """Clear the registry. Intended for tests and per-compile resets. + + Framework roots are also cleared so the next ``capture`` rediscovers + them — covers framework subpackages imported between compile passes. + """ + global _COUNTER, _FRAMEWORK_ROOTS + _REGISTRY.clear() + _BY_INFO.clear() + _COUNTER = itertools.count(1) + _FRAMEWORK_ROOTS = () + _is_framework_frame.cache_clear() + _RESOLVED_PATH_CACHE.clear() diff --git a/packages/reflex-base/src/reflex_base/inspector/shortcut.py b/packages/reflex-base/src/reflex_base/inspector/shortcut.py new file mode 100644 index 00000000000..7ec706af18f --- /dev/null +++ b/packages/reflex-base/src/reflex_base/inspector/shortcut.py @@ -0,0 +1,89 @@ +"""Parse the keyboard shortcut string from ``FrontendInspectorPlugin(shortcut=...)``.""" + +from __future__ import annotations + +import dataclasses + +from reflex_base.utils.exceptions import ConfigError + +_MODIFIER_ALIASES = { + "cmd": "meta", + "command": "meta", + "super": "meta", + "win": "meta", + "windows": "meta", + "option": "alt", + "opt": "alt", + "control": "ctrl", +} + +_VALID_MODIFIERS = ("alt", "ctrl", "meta", "shift") + + +@dataclasses.dataclass(frozen=True, slots=True) +class Shortcut: + """A normalized keyboard shortcut. + + ``key`` is lower-case and matched against ``KeyboardEvent.key.toLowerCase()`` + in the browser. The four boolean fields map onto the corresponding + ``KeyboardEvent`` modifier properties. + """ + + key: str + alt: bool = False + ctrl: bool = False + meta: bool = False + shift: bool = False + + def to_json_payload(self) -> dict[str, str | bool]: + """Return the shortcut as a JSON-serialisable dict. + + Returns: + The shortcut fields as a plain dict, suitable for ``json.dumps``. + """ + return dataclasses.asdict(self) + + +def parse_shortcut(value: str) -> Shortcut: + """Parse a ``"alt+x"``-style shortcut string. + + Args: + value: The raw shortcut from the config. + + Returns: + A normalized :class:`Shortcut`. + + Raises: + ConfigError: If the string is empty, has no key, or contains an + unknown modifier. + """ + if not value or not value.strip(): + msg = "FrontendInspectorPlugin shortcut must be a non-empty string." + raise ConfigError(msg) + + parts = [p.strip().lower() for p in value.split("+")] + if any(not p for p in parts): + msg = f"FrontendInspectorPlugin shortcut={value!r} has empty segments." + raise ConfigError(msg) + + *raw_modifiers, key = parts + + modifiers: set[str] = set() + for raw in raw_modifiers: + normalized = _MODIFIER_ALIASES.get(raw, raw) + if normalized not in _VALID_MODIFIERS: + msg = ( + f"FrontendInspectorPlugin shortcut={value!r} contains unknown " + f"modifier {raw!r}; expected one of " + f"{_VALID_MODIFIERS + tuple(_MODIFIER_ALIASES)}." + ) + raise ConfigError(msg) + modifiers.add(normalized) + + return Shortcut( + key=key, + alt="alt" in modifiers, + ctrl="ctrl" in modifiers, + meta="meta" in modifiers, + shift="shift" in modifiers, + ) diff --git a/packages/reflex-base/src/reflex_base/inspector/state.py b/packages/reflex-base/src/reflex_base/inspector/state.py new file mode 100644 index 00000000000..19cddb7a87c --- /dev/null +++ b/packages/reflex-base/src/reflex_base/inspector/state.py @@ -0,0 +1,28 @@ +"""Global toggle for the frontend inspector. + +A single boolean module-level flag is the source of truth. ``Config`` flips it +during ``_post_init`` so it is set before the user's app module imports. +""" + +from __future__ import annotations + +_ENABLED: bool = False + + +def set_enabled(on: bool) -> None: + """Set whether the inspector is active. + + Args: + on: True to enable, False to disable. + """ + global _ENABLED + _ENABLED = on + + +def is_enabled() -> bool: + """Return whether the inspector is currently active. + + Returns: + True if the inspector should capture call sites and emit ``data-rx``. + """ + return _ENABLED diff --git a/packages/reflex-base/src/reflex_base/plugins/__init__.py b/packages/reflex-base/src/reflex_base/plugins/__init__.py index f3ef5aa971c..07dbe4793b9 100644 --- a/packages/reflex-base/src/reflex_base/plugins/__init__.py +++ b/packages/reflex-base/src/reflex_base/plugins/__init__.py @@ -11,6 +11,7 @@ PageContext, PageDefinition, ) +from .frontend_inspector import FrontendInspectorPlugin from .sitemap import SitemapPlugin from .tailwind_v3 import TailwindV3Plugin from .tailwind_v4 import TailwindV4Plugin @@ -21,6 +22,7 @@ "CompileContext", "CompilerHooks", "ComponentAndChildren", + "FrontendInspectorPlugin", "PageContext", "PageDefinition", "Plugin", diff --git a/packages/reflex-base/src/reflex_base/plugins/base.py b/packages/reflex-base/src/reflex_base/plugins/base.py index 082258ddb9c..d3605daa8f3 100644 --- a/packages/reflex-base/src/reflex_base/plugins/base.py +++ b/packages/reflex-base/src/reflex_base/plugins/base.py @@ -99,6 +99,19 @@ def get_frontend_dependencies( """ return [] + def start_compile(self, app: "App") -> None: + """Lifecycle hook fired before pages are evaluated. + + Use this to flip runtime state that must be set before user code in + ``add_page`` callbacks runs, or to mutate the ``App`` (e.g. extend + ``app.head_components``) before ``compile_document_root`` is called. + ``pre_compile`` runs *after* page evaluation and is too late for + either job. + + Args: + app: The Reflex ``App`` being compiled. + """ + def get_static_assets( self, **context: Unpack[CommonContext] ) -> Sequence[tuple[Path, str | bytes]]: diff --git a/packages/reflex-base/src/reflex_base/plugins/frontend_inspector.py b/packages/reflex-base/src/reflex_base/plugins/frontend_inspector.py new file mode 100644 index 00000000000..53f972cb108 --- /dev/null +++ b/packages/reflex-base/src/reflex_base/plugins/frontend_inspector.py @@ -0,0 +1,282 @@ +"""Plugin for the dev-only frontend inspector. + +The inspector maps rendered DOM nodes back to the Python ``Component`` +call site that produced them. Wiring it up requires multiple compile-time +artifacts; this plugin owns all of them so users opt in with a single +``rxconfig.py`` entry: + +.. code-block:: python + + config = rx.Config( + app_name="my_app", + plugins=[rx.plugins.FrontendInspectorPlugin()], + ) + +The plugin is a no-op under ``REFLEX_ENV_MODE=prod`` so the same +``rxconfig.py`` works for ``reflex run`` and ``reflex export``. +""" + +from __future__ import annotations + +import dataclasses +import functools +import json +from collections.abc import Sequence +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from typing_extensions import Unpack + +from reflex_base import constants +from reflex_base.inspector import ( + DATA_ATTR, + EDITOR_URL, + INSPECTOR_CSS_URL, + INSPECTOR_JS_URL, + INSPECTOR_PLUGIN_FILE, + PUBLIC_DIRNAME, + SHORTCUT_CONFIG_KEY, + SOURCE_MAP_FILENAME, + SOURCE_MAP_URL, + WINDOW_CONFIG_KEY, + capture, + state, +) +from reflex_base.inspector.shortcut import parse_shortcut + +from .base import CommonContext, Plugin, PreCompileContext + +if TYPE_CHECKING: + from reflex.app import App + from reflex_base.components.component import Component + +LAUNCH_EDITOR_VERSION = "^2.6.1" +_INSPECTOR_VITE_IMPORT = "reflexInspectorPlugin" +_VITE_INSPECTOR_IMPORT_LINE = ( + f'import {_INSPECTOR_VITE_IMPORT} from "./{INSPECTOR_PLUGIN_FILE}";\n' +) +_VITE_INSPECTOR_PLUGIN_CALL = f" {_INSPECTOR_VITE_IMPORT}(),\n" +_VITE_PLUGINS_ANCHOR = " safariCacheBustPlugin(),\n" + + +@dataclasses.dataclass +class FrontendInspectorPlugin(Plugin): + """Maps rendered DOM nodes back to their Python call site (dev-only). + + Attributes: + shortcut: Keyboard shortcut for toggling the inspector. Modifier + aliases like ``cmd``/``option`` are accepted. + editor: Editor invocation forwarded to ``launch-editor`` via + ``REFLEX_EDITOR``. Empty string defers to the user's existing + ``REFLEX_EDITOR``/``VISUAL``/``EDITOR`` environment variables. + """ + + shortcut: str = "alt+x" + editor: str = "" + _injected_into_apps: set[int] = dataclasses.field( + default_factory=set, init=False, repr=False + ) + + def _is_active(self) -> bool: + # `environment` imports back from `reflex_base.plugins`; lazy import + # avoids the cycle. + from reflex_base.environment import environment + + return environment.REFLEX_ENV_MODE.get() != constants.Env.PROD + + def start_compile(self, app: App) -> None: + """Reset capture, flip the runtime flag, and inject head scripts. + + ``app.head_components`` has to be extended here rather than in + ``pre_compile`` because ``compile_document_root`` reads it during + the page-rendering phase, which runs before ``pre_compile``. + + Args: + app: The Reflex App being compiled. + """ + active = self._is_active() + # Force off so the inspector's own ``