diff --git a/docs/latest/advanced/partials.md b/docs/latest/advanced/partials.md
index ff9c608846a..62a21e4a765 100644
--- a/docs/latest/advanced/partials.md
+++ b/docs/latest/advanced/partials.md
@@ -208,6 +208,66 @@ Partial updates can be animated using the browser's
alongside `f-client-nav` to enable smooth animated transitions between pages
with zero JavaScript animation code.
+## Loading indicators
+
+When a partial request is in flight, you may want to show a loading spinner or
+disable a button. Fresh supports this through the `_freshIndicator` property.
+
+Attach an object with a `value` property to any element that triggers a partial
+navigation. Fresh will set `value` to `true` when the request starts and back to
+`false` when it completes (or fails).
+
+```tsx
+import { useSignal } from "@preact/signals";
+
+function NavLink() {
+ const loading = useSignal(false);
+
+ return (
+ {
+ if (el) el._freshIndicator = loading;
+ }}
+ >
+ {loading.value ? "Loading..." : "Go"}
+
+ );
+}
+```
+
+This works for links, forms, and submit buttons. For form submissions, Fresh
+checks the submitter element (e.g. the clicked button) first, then falls back to
+the form element itself. This lets you show per-button indicators when a form
+has multiple submit buttons.
+
+```tsx
+import { useSignal } from "@preact/signals";
+
+function MyForm() {
+ const saving = useSignal(false);
+
+ return (
+
+ );
+}
+```
+
+> [info]: Any object with a mutable `value` property works — Preact signals are
+> the most convenient choice because they automatically re-render the component
+> when the value changes.
+
## Bypassing or disabling Partials
If you want to exempt a particular element from triggering a partial request
diff --git a/packages/fresh/src/runtime/client/partials.ts b/packages/fresh/src/runtime/client/partials.ts
index bf8cf78aa92..77d3c3431b3 100644
--- a/packages/fresh/src/runtime/client/partials.ts
+++ b/packages/fresh/src/runtime/client/partials.ts
@@ -278,6 +278,14 @@ document.addEventListener("submit", async (e) => {
// this check, every form inside f-client-nav would be intercepted
// because el.action is always non-empty (defaults to the current URL).
if (hasExplicitPartial && rawPartialUrl !== "") {
+ // deno-lint-ignore no-explicit-any
+ const indicator = ((e.submitter as any)?._freshIndicator ??
+ // deno-lint-ignore no-explicit-any
+ (el as any)._freshIndicator) as { value: boolean } | undefined;
+ if (indicator !== undefined) {
+ indicator.value = true;
+ }
+
e.preventDefault();
const partialUrl = new URL(rawPartialUrl, location.href);
@@ -295,9 +303,15 @@ document.addEventListener("submit", async (e) => {
init = { body: new FormData(el, e.submitter), method: lowerMethod };
}
- await withViewTransition(async () => {
- await fetchPartials(actionUrl, partialUrl, true, init);
- });
+ try {
+ await withViewTransition(async () => {
+ await fetchPartials(actionUrl, partialUrl, true, init);
+ });
+ } finally {
+ if (indicator !== undefined) {
+ indicator.value = false;
+ }
+ }
}
}
});
diff --git a/packages/fresh/tests/partials_test.tsx b/packages/fresh/tests/partials_test.tsx
index 965cc134c05..1ef3a78f5a8 100644
--- a/packages/fresh/tests/partials_test.tsx
+++ b/packages/fresh/tests/partials_test.tsx
@@ -3281,3 +3281,220 @@ Deno.test({
});
},
});
+
+Deno.test({
+ name: "partials - submit form indicator on button",
+ fn: async () => {
+ const app = testApp()
+ .get("/partial", (ctx) => {
+ return ctx.render(
+
+
+ done
+
+ ,
+ );
+ })
+ .get("/", (ctx) => {
+ return ctx.render(
+
+
+ ,
+ );
+ });
+
+ await withBrowserApp(app, async (page, address) => {
+ await page.goto(address, { waitUntil: "load" });
+ await page.locator(".init").wait();
+
+ // Attach a tracking indicator to the submit button
+ await page.evaluate(() => {
+ const btn = document.querySelector(".update")!;
+ const indicator = { _wasTrue: false, _value: false };
+ Object.defineProperty(indicator, "value", {
+ get() {
+ return this._value;
+ },
+ set(v: boolean) {
+ this._value = v;
+ if (v) this._wasTrue = true;
+ },
+ });
+ // deno-lint-ignore no-explicit-any
+ (btn as any)._freshIndicator = indicator;
+ // deno-lint-ignore no-explicit-any
+ (window as any).__indicator = indicator;
+ });
+
+ await page.locator(".update").click();
+ await page.locator(".done").wait();
+
+ const result = await page.evaluate(() => {
+ // deno-lint-ignore no-explicit-any
+ const ind = (window as any).__indicator;
+ return { wasTrue: ind._wasTrue, currentValue: ind.value };
+ });
+ expect(result.wasTrue).toBe(true);
+ expect(result.currentValue).toBe(false);
+ });
+ },
+});
+
+Deno.test({
+ name: "partials - submit form indicator on form element",
+ fn: async () => {
+ const app = testApp()
+ .get("/partial", (ctx) => {
+ return ctx.render(
+
+
+ done
+
+ ,
+ );
+ })
+ .get("/", (ctx) => {
+ return ctx.render(
+
+
+ ,
+ );
+ });
+
+ await withBrowserApp(app, async (page, address) => {
+ await page.goto(address, { waitUntil: "load" });
+ await page.locator(".init").wait();
+
+ // Attach indicator to the form element (not the button)
+ await page.evaluate(() => {
+ const form = document.querySelector(".myform")!;
+ const indicator = { _wasTrue: false, _value: false };
+ Object.defineProperty(indicator, "value", {
+ get() {
+ return this._value;
+ },
+ set(v: boolean) {
+ this._value = v;
+ if (v) this._wasTrue = true;
+ },
+ });
+ // deno-lint-ignore no-explicit-any
+ (form as any)._freshIndicator = indicator;
+ // deno-lint-ignore no-explicit-any
+ (window as any).__indicator = indicator;
+ });
+
+ await page.locator(".update").click();
+ await page.locator(".done").wait();
+
+ const result = await page.evaluate(() => {
+ // deno-lint-ignore no-explicit-any
+ const ind = (window as any).__indicator;
+ return { wasTrue: ind._wasTrue, currentValue: ind.value };
+ });
+ expect(result.wasTrue).toBe(true);
+ expect(result.currentValue).toBe(false);
+ });
+ },
+});
+
+Deno.test({
+ name: "partials - submit form indicator prefers submitter over form",
+ fn: async () => {
+ const app = testApp()
+ .get("/partial", (ctx) => {
+ return ctx.render(
+
+
+ done
+
+ ,
+ );
+ })
+ .get("/", (ctx) => {
+ return ctx.render(
+
+
+ ,
+ );
+ });
+
+ await withBrowserApp(app, async (page, address) => {
+ await page.goto(address, { waitUntil: "load" });
+ await page.locator(".init").wait();
+
+ // Attach indicators to both form and button
+ await page.evaluate(() => {
+ function makeIndicator(name: string) {
+ const indicator = { _wasTrue: false, _value: false };
+ Object.defineProperty(indicator, "value", {
+ get() {
+ return this._value;
+ },
+ set(v: boolean) {
+ this._value = v;
+ if (v) this._wasTrue = true;
+ },
+ });
+ // deno-lint-ignore no-explicit-any
+ (window as any)[name] = indicator;
+ return indicator;
+ }
+
+ const btn = document.querySelector(".update")!;
+ const form = document.querySelector(".myform")!;
+ // deno-lint-ignore no-explicit-any
+ (btn as any)._freshIndicator = makeIndicator("__btnIndicator");
+ // deno-lint-ignore no-explicit-any
+ (form as any)._freshIndicator = makeIndicator("__formIndicator");
+ });
+
+ await page.locator(".update").click();
+ await page.locator(".done").wait();
+
+ const result = await page.evaluate(() => {
+ // deno-lint-ignore no-explicit-any
+ const w = window as any;
+ return {
+ btnWasTrue: w.__btnIndicator._wasTrue,
+ btnCurrent: w.__btnIndicator.value,
+ formWasTrue: w.__formIndicator._wasTrue,
+ formCurrent: w.__formIndicator.value,
+ };
+ });
+ // Submitter indicator should have been toggled
+ expect(result.btnWasTrue).toBe(true);
+ expect(result.btnCurrent).toBe(false);
+ // Form indicator should NOT have been used
+ expect(result.formWasTrue).toBe(false);
+ expect(result.formCurrent).toBe(false);
+ });
+ },
+});