Allow functions that solely update component properties to run in the frontend by setting js=True (#10500)

* changes

* add changeset

* revert

* changes

* add changeset

* changes

* fe changse

* notebook

* changes

* fix fe

* changes

* add changeset

* change

* add changeset

* notebook

* add tests

* changes

* changes

* changes

* changes

* changes

* type

* changes

* changes

* changes

* notebooks

* fix

* fix

* changes

* changes

* changes

* changes

* changes

* notebooks

* changes

* revert

* changes

* changes

* changes

* notebook

* changes

* done

* changes

* add changeset

* update

* changes

* add changeset

* added a guide

---------

Co-authored-by: gradio-pr-bot <gradio-pr-bot@users.noreply.github.com>
This commit is contained in:
Abubakar Abid 2025-02-28 12:45:20 -08:00 committed by GitHub
parent 9b6a29253f
commit 16d419b9f1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 185 additions and 15 deletions

View File

@ -0,0 +1,7 @@
---
"@gradio/client": minor
"@gradio/core": minor
"gradio": minor
---
feat:Allow functions that solely update component properties to run in the frontend by setting `js=True`

View File

@ -271,6 +271,7 @@ export interface Dependency {
stream_every: number;
like_user_message: boolean;
event_specific_args: string[];
js_implementation: string | null;
}
export interface DependencyTypes {

View File

@ -0,0 +1 @@
{"cells": [{"cell_type": "markdown", "id": "302934307671667531413257853548643485645", "metadata": {}, "source": ["# Gradio Demo: todo_list_js"]}, {"cell_type": "code", "execution_count": null, "id": "272996653310673477252411125948039410165", "metadata": {}, "outputs": [], "source": ["!pip install -q gradio "]}, {"cell_type": "code", "execution_count": null, "id": "288918539441861185822528903084949547379", "metadata": {}, "outputs": [], "source": ["\"\"\"\n", "This is a simple todo list app that allows you to edit tasks and mark tasks as complete.\n", "All actions are performed on the client side.\n", "\"\"\"\n", "import gradio as gr\n", "\n", "tasks = [\"Get a job\", \"Marry rich\", \"\", \"\", \"\", \"\"]\n", "textboxes = []\n", "buttons = []\n", "with gr.Blocks() as demo:\n", " with gr.Row():\n", " with gr.Column(scale=3):\n", " gr.Markdown(\"# A Simple Interactive Todo List\")\n", " with gr.Column(scale=2):\n", " with gr.Row():\n", " freeze_button = gr.Button(\"Freeze tasks\", variant=\"stop\")\n", " edit_button = gr.Button(\"Edit tasks\")\n", " for i in range(6):\n", " with gr.Row() as r:\n", " t = gr.Textbox(tasks[i], placeholder=\"Enter a task\", show_label=False, container=False, scale=7, interactive=True)\n", " b = gr.Button(\"\u2714\ufe0f\", interactive=bool(tasks[i]), variant=\"primary\" if tasks[i] else \"secondary\")\n", " textboxes.append(t)\n", " buttons.append(b)\n", " t.change(lambda : gr.Button(interactive=True, variant=\"primary\"), None, b, js=True)\n", " b.click(lambda : gr.Row(visible=False), None, r, js=True)\n", " freeze_button.click(lambda : [gr.Textbox(interactive=False), gr.Textbox(interactive=False), gr.Textbox(interactive=False), gr.Textbox(interactive=False), gr.Textbox(interactive=False), gr.Textbox(interactive=False)], None, textboxes, js=True)\n", " edit_button.click(lambda : [gr.Textbox(interactive=True), gr.Textbox(interactive=True), gr.Textbox(interactive=True), gr.Textbox(interactive=True), gr.Textbox(interactive=True), gr.Textbox(interactive=True)], None, textboxes, js=True)\n", " freeze_button.click(lambda : [gr.Button(visible=False), gr.Button(visible=False), gr.Button(visible=False), gr.Button(visible=False), gr.Button(visible=False), gr.Button(visible=False)], None, buttons, js=True)\n", " edit_button.click(lambda : [gr.Button(visible=True), gr.Button(visible=True), gr.Button(visible=True), gr.Button(visible=True), gr.Button(visible=True), gr.Button(visible=True)], None, buttons, js=True)\n", "\n", "if __name__ == \"__main__\":\n", " demo.launch()\n"]}], "metadata": {}, "nbformat": 4, "nbformat_minor": 5}

32
demo/todo_list_js/run.py Normal file
View File

@ -0,0 +1,32 @@
"""
This is a simple todo list app that allows you to edit tasks and mark tasks as complete.
All actions are performed on the client side.
"""
import gradio as gr
tasks = ["Get a job", "Marry rich", "", "", "", ""]
textboxes = []
buttons = []
with gr.Blocks() as demo:
with gr.Row():
with gr.Column(scale=3):
gr.Markdown("# A Simple Interactive Todo List")
with gr.Column(scale=2):
with gr.Row():
freeze_button = gr.Button("Freeze tasks", variant="stop")
edit_button = gr.Button("Edit tasks")
for i in range(6):
with gr.Row() as r:
t = gr.Textbox(tasks[i], placeholder="Enter a task", show_label=False, container=False, scale=7, interactive=True)
b = gr.Button("✔️", interactive=bool(tasks[i]), variant="primary" if tasks[i] else "secondary")
textboxes.append(t)
buttons.append(b)
t.change(lambda : gr.Button(interactive=True, variant="primary"), None, b, js=True)
b.click(lambda : gr.Row(visible=False), None, r, js=True)
freeze_button.click(lambda : [gr.Textbox(interactive=False), gr.Textbox(interactive=False), gr.Textbox(interactive=False), gr.Textbox(interactive=False), gr.Textbox(interactive=False), gr.Textbox(interactive=False)], None, textboxes, js=True)
edit_button.click(lambda : [gr.Textbox(interactive=True), gr.Textbox(interactive=True), gr.Textbox(interactive=True), gr.Textbox(interactive=True), gr.Textbox(interactive=True), gr.Textbox(interactive=True)], None, textboxes, js=True)
freeze_button.click(lambda : [gr.Button(visible=False), gr.Button(visible=False), gr.Button(visible=False), gr.Button(visible=False), gr.Button(visible=False), gr.Button(visible=False)], None, buttons, js=True)
edit_button.click(lambda : [gr.Button(visible=True), gr.Button(visible=True), gr.Button(visible=True), gr.Button(visible=True), gr.Button(visible=True), gr.Button(visible=True)], None, buttons, js=True)
if __name__ == "__main__":
demo.launch()

View File

@ -29,6 +29,7 @@ import httpx
from anyio import CapacityLimiter
from gradio_client import utils as client_utils
from gradio_client.documentation import document
from groovy import transpile
from gradio import (
analytics,
@ -507,7 +508,7 @@ class BlockFunction:
concurrency_id: str | None = None,
tracks_progress: bool = False,
api_name: str | Literal[False] = False,
js: str | None = None,
js: str | Literal[True] | None = None,
show_progress: Literal["full", "minimal", "hidden"] = "full",
show_progress_on: Sequence[Component] | None = None,
cancels: list[int] | None = None,
@ -527,6 +528,7 @@ class BlockFunction:
like_user_message: bool = False,
event_specific_args: list[str] | None = None,
page: str = "",
js_implementation: str | None = None,
):
self.fn = fn
self._id = _id
@ -562,6 +564,8 @@ class BlockFunction:
self.renderable = renderable
self.rendered_in = rendered_in
self.page = page
if js_implementation:
self.fn.__js_implementation__ = js_implementation # type: ignore
# We need to keep track of which events are cancel events
# so that the client can call the /cancel route directly
@ -626,6 +630,7 @@ class BlockFunction:
"stream_every": self.stream_every,
"like_user_message": self.like_user_message,
"event_specific_args": self.event_specific_args,
"js_implementation": getattr(self.fn, "__js_implementation__", None),
}
@ -713,7 +718,7 @@ class BlocksConfig:
show_progress: Literal["full", "minimal", "hidden"] = "full",
show_progress_on: Component | Sequence[Component] | None = None,
api_name: str | None | Literal[False] = None,
js: str | None = None,
js: str | Literal[True] | None = None,
no_target: bool = False,
queue: bool = True,
batch: bool = False,
@ -733,6 +738,7 @@ class BlocksConfig:
stream_every: float = 0.5,
like_user_message: bool = False,
event_specific_args: list[str] | None = None,
js_implementation: str | None = None,
) -> tuple[BlockFunction, int]:
"""
Adds an event to the component's dependencies.
@ -853,6 +859,11 @@ class BlocksConfig:
rendered_in = LocalContext.renderable.get()
if js is True and inputs:
raise ValueError(
"Cannot create event: events with js=True cannot have inputs."
)
block_fn = BlockFunction(
fn,
inputs,
@ -888,6 +899,7 @@ class BlocksConfig:
like_user_message=like_user_message,
event_specific_args=event_specific_args,
page=self.root_block.current_page,
js_implementation=js_implementation,
)
self.fns[self.fn_id] = block_fn
@ -1060,7 +1072,7 @@ class Blocks(BlockContext, BlocksEvents, metaclass=BlocksMeta):
title: str = "Gradio",
css: str | None = None,
css_paths: str | Path | Sequence[str | Path] | None = None,
js: str | None = None,
js: str | Literal[True] | None = None,
head: str | None = None,
head_paths: str | Path | Sequence[str | Path] | None = None,
fill_height: bool = False,
@ -2196,7 +2208,7 @@ Received inputs:
"components": [],
"css": self.css,
"connect_heartbeat": False,
"js": self.js,
"js": cast(str | Literal[True] | None, self.js),
"head": self.head,
"title": self.title or "Gradio",
"space_id": self.space_id,
@ -2233,6 +2245,30 @@ Received inputs:
)
return config
def transpile_to_js(self, quiet: bool = False):
fns_to_transpile = [
fn.fn for fn in self.fns.values() if fn.fn and fn.js is True
]
num_to_transpile = len(fns_to_transpile)
if not quiet and num_to_transpile > 0:
print("********************************************")
print("* Trying to transpile functions from Python -> JS for performance\n")
for index, fn in enumerate(fns_to_transpile):
if not quiet:
print(f"* ({index + 1}/{num_to_transpile}) {fn.__name__}: ", end="")
if getattr(fn, "__js_implementation__", None) is None: # type: ignore
try:
fn.__js_implementation__ = transpile(fn, validate=True) # type: ignore
if not quiet:
print("")
except Exception as e:
if not quiet:
print("", e, end="\n\n")
elif not quiet:
print("")
if not quiet and num_to_transpile > 0:
print("********************************************\n")
def __enter__(self):
render_context = get_render_context()
if render_context is None:
@ -2508,6 +2544,7 @@ Received inputs:
self.pwa = utils.get_space() is not None if pwa is None else pwa
self.max_threads = max_threads
self._queue.max_thread_count = max_threads
self.transpile_to_js(quiet=quiet)
self.config = self.get_config_file()
self.ssr_mode = (

View File

@ -96,7 +96,7 @@ class ChatInterface(Blocks):
flagging_dir: str = ".gradio/flagged",
css: str | None = None,
css_paths: str | Path | Sequence[str | Path] | None = None,
js: str | None = None,
js: str | Literal[True] | None = None,
head: str | None = None,
head_paths: str | Path | Sequence[str | Path] | None = None,
analytics_enabled: bool | None = None,

View File

@ -36,7 +36,7 @@ INTERFACE_TEMPLATE = '''
cancels: dict[str, Any] | list[dict[str, Any]] | None = None,
every: Timer | float | None = None,
trigger_mode: Literal["once", "multiple", "always_last"] | None = None,
js: str | None = None,
js: str | Literal[True] | None = None,
concurrency_limit: int | None | Literal["default"] = "default",
concurrency_id: str | None = None,
show_api: bool = True,

View File

@ -370,7 +370,7 @@ class BlocksConfigDict(TypedDict):
components: list[dict[str, Any]]
css: str | None
connect_heartbeat: bool
js: str | None
js: str | Literal[True] | None
head: str | None
title: str
space_id: str | None

View File

@ -587,7 +587,7 @@ class EventListener(str):
postprocess: bool = True,
cancels: dict[str, Any] | list[dict[str, Any]] | None = None,
trigger_mode: Literal["once", "multiple", "always_last"] | None = None,
js: str | None = None,
js: str | Literal[True] | None = None,
concurrency_limit: int | None | Literal["default"] = "default",
concurrency_id: str | None = None,
show_api: bool = True,
@ -751,7 +751,7 @@ def on(
postprocess: bool = True,
cancels: dict[str, Any] | list[dict[str, Any]] | None = None,
trigger_mode: Literal["once", "multiple", "always_last"] | None = None,
js: str | None = None,
js: str | Literal[True] | None = None,
concurrency_limit: int | None | Literal["default"] = "default",
concurrency_id: str | None = None,
show_api: bool = True,

View File

@ -127,7 +127,7 @@ class Interface(Blocks):
concurrency_limit: int | None | Literal["default"] = "default",
css: str | None = None,
css_paths: str | Path | Sequence[str | Path] | None = None,
js: str | None = None,
js: str | Literal[True] | None = None,
head: str | None = None,
head_paths: str | Path | Sequence[str | Path] | None = None,
additional_inputs: str | Component | Sequence[str | Component] | None = None,
@ -947,7 +947,7 @@ class TabbedInterface(Blocks):
theme: Theme | str | None = None,
analytics_enabled: bool | None = None,
css: str | None = None,
js: str | None = None,
js: str | Literal[True] | None = None,
head: str | None = None,
):
"""

View File

@ -1,4 +1,4 @@
# Styling
# Gradio Themes
Gradio themes are the easiest way to customize the look and feel of your app. You can choose from a variety of themes, or create your own. To do so, pass the `theme=` kwarg to the `Interface` constructor. For example:

View File

@ -0,0 +1,71 @@
# Client Side Functions
Gradio allows you to run certain "simple" functions directly in the browser by setting `js=True` in your event listeners. This will **automatically convert your Python code into JavaSCript**, which significantly improves the responsiveness of your app by avoiding a round trip to the server for simple UI updates.
The difference in responsiveness is most noticeable on hosted applications (like Hugging Face Spaces), when the server is under heavy load, with high-latency connections, or when many users are accessing the app simultaneously.
## When to Use Client Side Functions
Client side functions are ideal for updating component properties (like visibility, placeholders, interactive state, or styling).
Here's a basic example:
```py
import gradio as gr
with gr.Blocks() as demo:
with gr.Row() as row:
btn = gr.Button("Hide this row")
# This function runs in the browser without a server roundtrip
btn.click(
lambda: gr.Row(visible=False),
None,
row,
js=True
)
demo.launch()
```
## Limitations
Client side functions have some important restrictions:
* They can only update component properties (not values)
* They cannot take any inputs
Here are some functions that will work with `js=True`:
```py
# Simple property updates
lambda: gr.Textbox(lines=4)
# Multiple component updates
lambda: [gr.Textbox(lines=4), gr.Button(interactive=False)]
# Using gr.update() for property changes
lambda: gr.update(visible=True, interactive=False)
```
We are working to increase the space of functions that can be transpiled to JavaScript so that they can be run in the browser. [Follow the Groovy library for more info](https://github.com/abidlabs/groovy-transpiler).
## Complete Example
Here's a more complete example showing how client side functions can improve the user experience:
$code_todo_list_js
## Behind the Scenes
When you set `js=True`, Gradio:
1. Transpiles your Python function to JavaScript
2. Runs the function directly in the browser
3. Still sends the request to the server (for consistency and to handle any side effects)
This provides immediate visual feedback while ensuring your application state remains consistent.

View File

@ -289,7 +289,7 @@
trigger_id: trigger_id
};
if (dep.frontend_fn) {
if (dep.frontend_fn && typeof dep.frontend_fn !== "boolean") {
dep
.frontend_fn(
payload.data.concat(
@ -314,6 +314,21 @@
);
} else {
if (dep.backend_fn) {
if (dep.js_implementation) {
let js_fn = new AsyncFunction(
`let result = await (${dep.js_implementation})(...arguments);
return (!Array.isArray(result)) ? [result] : result;`
);
js_fn(...payload.data)
.then((js_result) => {
handle_update(js_result, dep_index);
payload.js_implementation = true;
})
.catch((error) => {
console.error(error);
payload.js_implementation = false;
});
}
trigger_prediction(dep, payload);
}
}
@ -391,6 +406,9 @@
submit_map.set(dep_index, submission);
for await (const message of submission) {
if (payload.js_implementation) {
return;
}
if (message.type === "data") {
handle_data(message);
} else if (message.type === "render") {

View File

@ -478,12 +478,12 @@ export const AsyncFunction: new (
* @returns The function, or null if the source code is invalid or missing
*/
export function process_frontend_fn(
source: string | null | undefined | false,
source: string | null | undefined | boolean,
backend_fn: boolean,
input_length: number,
output_length: number
): ((...args: unknown[]) => Promise<unknown[]>) | null {
if (!source) return null;
if (!source || source === true) return null;
const wrap = backend_fn ? input_length === 1 : output_length === 1;
try {

View File

@ -43,6 +43,7 @@ export interface Payload {
data: unknown[];
event_data?: unknown | null;
trigger_id?: number | null;
js_implementation?: boolean | null;
}
/** A dependency as received from the backend */
@ -75,6 +76,7 @@ export interface Dependency {
stream_every: number;
like_user_message: boolean;
event_specific_args: string[];
js_implementation: string | null;
}
interface TypeDescription {

View File

@ -3,6 +3,7 @@ anyio>=3.0,<5.0
audioop-lts<1.0; python_version >= "3.13" #it provides support for 'audioop' module removed in latest python version used by pydub
fastapi>=0.115.2,<1.0
ffmpy
groovy~=0.1
gradio_client==1.7.2
httpx>=0.24.1
huggingface_hub>=0.28.1