Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
60 changes: 60 additions & 0 deletions docs/latest/advanced/partials.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<a
href="/next-page"
f-partial="/partials/next-page"
ref={(el) => {
if (el) el._freshIndicator = loading;
}}
>
{loading.value ? "Loading..." : "Go"}
</a>
);
}
```

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 (
<form action="/save" f-partial="/partials/save">
{/* indicator is on the button, not the form */}
<button
type="submit"
ref={(el) => {
if (el) el._freshIndicator = saving;
}}
>
{saving.value ? "Saving..." : "Save"}
</button>
</form>
);
}
```

> [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
Expand Down
20 changes: 17 additions & 3 deletions packages/fresh/src/runtime/client/partials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
}
}
}
}
});
Expand Down
217 changes: 217 additions & 0 deletions packages/fresh/tests/partials_test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<Doc>
<Partial name="foo">
<p class="done">done</p>
</Partial>
</Doc>,
);
})
.get("/", (ctx) => {
return ctx.render(
<Doc>
<div f-client-nav>
<form action="/partial">
<Partial name="foo">
<p class="init">init</p>
</Partial>
<button type="submit" class="update">
update
</button>
</form>
</div>
</Doc>,
);
});

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(
<Doc>
<Partial name="foo">
<p class="done">done</p>
</Partial>
</Doc>,
);
})
.get("/", (ctx) => {
return ctx.render(
<Doc>
<div f-client-nav>
<form action="/partial" class="myform">
<Partial name="foo">
<p class="init">init</p>
</Partial>
<button type="submit" class="update">
update
</button>
</form>
</div>
</Doc>,
);
});

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(
<Doc>
<Partial name="foo">
<p class="done">done</p>
</Partial>
</Doc>,
);
})
.get("/", (ctx) => {
return ctx.render(
<Doc>
<div f-client-nav>
<form action="/partial" class="myform">
<Partial name="foo">
<p class="init">init</p>
</Partial>
<button type="submit" class="update">
update
</button>
</form>
</div>
</Doc>,
);
});

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);
});
},
});
Loading