Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/hot-baboons-flow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@gradio/html": minor
"gradio": minor
---

feat:Add `watch` function to gr.HTML js_on_load
2 changes: 1 addition & 1 deletion demo/super_html/run.ipynb

Large diffs are not rendered by default.

32 changes: 32 additions & 0 deletions demo/super_html/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,38 @@ def on_button_click(evt: gr.EventData):
form.submit(lambda x: x, form, outputs=output_box)
output_box.submit(lambda x: x, output_box, outputs=form)

gr.Markdown("""
# Watch API
Use `watch` inside `js_on_load` to run a callback after the template re-renders whenever specific props change. The callback takes no arguments — read current values from `props` directly.
""")
watch_html = gr.HTML(
value=0,
html_template="""
<div>
<div>Will 'submit' at 10, currently ${value}</div>
<button class="inc">+1</button>
<button class="reset">Reset</button>
</div>
""",
js_on_load="""
element.querySelector('.inc').addEventListener('click', () => {
props.value++;
});
element.querySelector('.reset').addEventListener('click', () => {
props.value = 0;
});

watch('value', () => {
if (props.value === 10) {
trigger('submit');
}
});
""",
elem_id="watch_demo",
)
watch_output = gr.Textbox(label="Watch Output")
watch_html.submit(lambda x: x, watch_html, outputs=watch_output)

gr.Markdown("""
# Extending gr.HTML for new Components
You can create your own Components by extending the gr.HTML class.
Expand Down
2 changes: 1 addition & 1 deletion gradio/components/html.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ def __init__(
label: The label for this component. Is used as the header if there are a table of examples for this component. If None and used in a `gr.Interface`, the label will be the name of the parameter this component is assigned to.
html_template: A string representing the HTML template for this component as a JS template string and Handlebars template. The `${value}` tag will be replaced with the `value` parameter, and all other tags will be filled in with the values from `props`. This element can have children when used in a `with gr.HTML(...):` context, and the children will be rendered to replace `@children` substring, which cannot be nested inside any HTML tags.
css_template: A string representing the CSS template for this component as a JS template string and Handlebars template. The CSS will be automatically scoped to this component, and rules outside a block will target the component's root element. The `${value}` tag will be replaced with the `value` parameter, and all other tags will be filled in with the values from `props`.
js_on_load: A string representing the JavaScript code that will be executed when the component is loaded. The `element` variable refers to the HTML element of this component, and can be used to access children such as `element.querySelector()`. The `trigger` function can be used to trigger events, such as `trigger('click')`. The value and other props can be edited through `props`, e.g. `props.value = "new value"` which will re-render the HTML template. If `server_functions` is provided, a `server` object is also available in `js_on_load`, where each function is accessible as an async method, e.g. `server.list_files(path).then(files => ...)` or `const files = await server.list_files(path)`. The `upload` async function can be used to upload a JavaScript `File` object to the Gradio server, returning a dictionary with `path` (the server-side file path) and `url` (the public URL to access the file), e.g. `const { path, url } = await upload(file)`.
js_on_load: A string representing the JavaScript code that will be executed when the component is loaded. The `element` variable refers to the HTML element of this component, and can be used to access children such as `element.querySelector()`. The `trigger` function can be used to trigger events, such as `trigger('click')`. The value and other props can be edited through `props`, e.g. `props.value = "new value"` which will re-render the HTML template. If `server_functions` is provided, a `server` object is also available in `js_on_load`, where each function is accessible as an async method, e.g. `server.list_files(path).then(files => ...)` or `const files = await server.list_files(path)`. The `upload` async function can be used to upload a JavaScript `File` object to the Gradio server, returning a dictionary with `path` (the server-side file path) and `url` (the public URL to access the file), e.g. `const { path, url } = await upload(file)`. The `watch` function can be used to observe prop changes: `watch('value', () => { ... })` runs the callback after the template re-renders whenever `value` changes, or `watch(['value', 'color'], () => { ... })` to watch multiple props.`.
apply_default_css: If True, default Gradio CSS styles will be applied to the HTML component.
every: Continously calls `value` to recalculate it if `value` is a function (has no effect otherwise). Can provide a Timer whose tick resets `value`, or a float that provides the regular interval for the reset Timer.
inputs: Components that are used as inputs to calculate `value` if `value` is a function (has no effect otherwise). `value` is recalculated any time the inputs change.
Expand Down
18 changes: 17 additions & 1 deletion guides/03_building-with-blocks/06_custom-HTML-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,23 @@ element.addEventListener('click', (e) =>
);
```

You can trigger an event with any name. As long as the event name appears enclosed in quotes in your `js_on_load` string, you can attach a Python listener using `component.do_something(fn, ...)`. If it is not one of the standard Gradio event names, your IDE might not recognize it as an event, but it will still work as long as the event name matches in both the JS and Python code.
You can trigger an event with any name. As long as the event name appears enclosed in quotes in your `js_on_load` string, you can attach a Python listener using `component.do_something(fn, ...)`.

## Watching Props with `watch`

The `watch` function, available inside `js_on_load`, lets you run a callback whenever specific props change. The callback fires after the template has re-rendered, so the DOM is already up to date when your code runs. Read current values directly from `props` inside the callback.

```js
// Watch a single prop
watch('value', () => {
console.log('value is now:', props.value);
});

// Watch multiple props
watch(['value', 'color'], () => {
console.log('value or color changed');
});
```

## Server Functions

Expand Down
2 changes: 1 addition & 1 deletion js/html/Index.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
const gradio = new Gradio<HTMLEvents, HTMLProps>(props);

let _props = $derived({
value: gradio.props.value || "",
value: gradio.props.value ?? "",
label: gradio.shared.label,
visible: gradio.shared.visible,
...gradio.props.props
Expand Down
65 changes: 64 additions & 1 deletion js/html/shared/HTML.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@
let random_id = `html-${Math.random().toString(36).substring(2, 11)}`;
let style_element: HTMLStyleElement | null = null;
let reactiveProps: Record<string, any> = {};
let watchers: Map<
string,
Set<{ props: string[]; callback: () => void }>
> = new Map();
let pendingChanges: Set<string> = new Set();
const MAX_FLUSH_DEPTH = 20;
let flushDepth = 0;
let currentHtml = $state("");
let currentPreHtml = $state("");
let currentPostHtml = $state("");
Expand Down Expand Up @@ -298,13 +305,67 @@
}
}

function flushWatchers(): void {
if (pendingChanges.size === 0) return;
if (!mounted) return;
if (flushDepth >= MAX_FLUSH_DEPTH) {
console.warn("gr.HTML: too many cascading watch updates, breaking cycle");
pendingChanges.clear();
return;
}
const changed = new Set(pendingChanges);
pendingChanges.clear();
const notified = new Set<{ props: string[]; callback: () => void }>();
for (const prop of changed) {
const entries = watchers.get(prop);
if (!entries) continue;
for (const entry of entries) {
if (!notified.has(entry)) {
notified.add(entry);
}
}
}
flushDepth++;
for (const entry of notified) {
try {
entry.callback();
} catch (e) {
console.error("Error in watch callback:", e);
}
}
flushDepth--;
if (flushDepth === 0) {
queueMicrotask(() => {
flushDepth = 0;
});
}
}

function watch(
propOrProps: string | string[],
callback: () => void
): () => void {
const propList = Array.isArray(propOrProps) ? propOrProps : [propOrProps];
const entry = { props: propList, callback };
for (const prop of propList) {
if (!watchers.has(prop)) watchers.set(prop, new Set());
watchers.get(prop)!.add(entry);
}
return () => {
for (const prop of propList) {
watchers.get(prop)?.delete(entry);
}
};
}

function scheduleRender(): void {
if (!renderScheduled) {
renderScheduled = true;
queueMicrotask(() => {
renderScheduled = false;
renderHTML();
update_css();
flushWatchers();
});
}
}
Expand Down Expand Up @@ -362,6 +423,7 @@
target[property as string] = value;

if (oldValue !== value) {
pendingChanges.add(property as string);
scheduleRender();

if (
Expand Down Expand Up @@ -420,9 +482,10 @@
"props",
"server",
"upload",
"watch",
js_on_load
);
func(element, trigger, reactiveProps, server, upload_func);
func(element, trigger, reactiveProps, server, upload_func, watch);
} catch (error) {
console.error("Error executing js_on_load:", error);
}
Expand Down
11 changes: 11 additions & 0 deletions js/spa/test/super_html.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,17 @@ test("test HTML components", async ({ page }) => {
expect(vegetablesUnorderedHtml).toContain("<ul>");
}).toPass();

// Watch API test
await expect(page.locator("#watch_demo")).toContainText("currently 0");
const incButton = page.locator("#watch_demo .inc");
for (let i = 0; i < 10; i++) {
await incButton.click();
}
await expect(page.locator("#watch_demo")).toContainText("currently 10");
await expect(page.getByLabel("Watch Output")).toHaveValue("10");
await page.locator("#watch_demo .reset").click();
await expect(page.locator("#watch_demo")).toContainText("currently 0");

await expect(page.locator("body")).toContainText("Zalue is not defined");

const secondTodoCheckbox = page
Expand Down
Loading