SSR framework for Svelte 5 + Bun with islands-based selective hydration
Progressively enhancing forms with enhance
enhance is a Svelte attachment that progressively enhances a <form method="POST">. The same server-side action handler runs whether JavaScript is available or not — but with {@attach enhance(...)}, the client submits over fetch, the server returns a JSON MochiEnhanceResult instead of a re-rendered HTML page, and there’s no full-page reload.
<script>
import { enhance } from 'mochi-framework';
</script>
<form method="POST" action="?/login" {@attach enhance()}>
<input name="username" />
<input name="password" type="password" />
<button type="submit">Log in</button>
</form>The form must live inside a hydrated island (eg: mochi:hydrate, mochi:hydrate:visible). If hydration is skipped, the attachment never runs and the form falls back to a native HTML POST automatically — that’s the progressive-enhancement contract.
Attachments require Svelte 5.29+.
enhanceis a factory: even with no options, call it as{@attach enhance()}.
Wire format
enhance adds two headers to the POST: Accept: application/json and x-mochi-action: true. The server detects these and responds with one of four shapes:
type MochiEnhanceResult =
| { type: 'success'; status: number; data?: unknown }
| { type: 'failure'; status: number; data?: unknown }
| { type: 'redirect'; status: number; location: string }
| { type: 'error'; status?: number; error: unknown };The HTTP status is 200 for success, failure, and redirect — the body’s status field carries the action’s status. For error, the HTTP status matches the error code. data is encoded with devalue so Date, Map, Set, BigInt, and cyclic references survive the wire.
Default fallback
Without a callback, enhance runs a minimal default for each result type:
result.type | Default |
|---|---|
success | form.reset() |
failure | nothing (provide a callback to update the UI) |
redirect | window.location.assign(result.location) |
error | console.error('[mochi] enhance:', result.error) |
When the same component renders both as a hydrated island (where enhance will fire) and as a plain SSR-only child (where it won’t), read the auto-injected isHydratable prop to skip the SSR form-prop peek when the client will take over.
Submit callback
Pass a function as the argument. It runs once per submit and may return a result handler that fully replaces the default fallback:
<script>
import { enhance } from 'mochi-framework';
import type { MochiSubmitFunction } from 'mochi-framework';
let errorMessage = $state<string | null>(null);
let pending = $state(false);
const handleLogin: MochiSubmitFunction<{ username: string }, { error: string }> = ({ formData }) => {
pending = true;
errorMessage = null;
return ({ result, formElement }) => {
pending = false;
if (result.type === 'success') {
formElement.reset();
} else if (result.type === 'failure') {
errorMessage = result.data?.error ?? 'Sign-in failed';
}
};
};
</script>
<form method="POST" {@attach enhance(handleLogin)}>
…
<button type="submit" disabled={pending}>{pending ? 'Signing in…' : 'Log in'}</button>
</form>The result handler receives { result, formElement, formData, action, update }. Calling update({ reset?: boolean }) re-invokes the default fallback — useful when you only want to layer extra behavior on top of the framework default.
onPending
Instead of managing a pending flag inside the submit function, pass an options object with onPending. The callback fires with true immediately before the fetch and false once the result handler has settled (or when the submission is cancelled):
<script>
import { enhance } from 'mochi-framework';
import type { MochiEnhanceOptions, MochiSubmitFunction } from 'mochi-framework';
let errorMessage = $state<string | null>(null);
let pending = $state(false);
const handleLogin: MochiSubmitFunction<{ username: string }, { error: string }> = () => {
errorMessage = null;
return ({ result }) => {
if (result.type === 'failure') {
errorMessage = result.data?.error ?? 'Sign-in failed';
}
};
};
const opts: MochiEnhanceOptions<{ username: string }, { error: string }> = {
submit: handleLogin,
onPending: (v) => {
pending = v;
},
};
</script>
<form method="POST" {@attach enhance(opts)}>
…
<button type="submit" disabled={pending}>{pending ? 'Signing in…' : 'Log in'}</button>
</form>onPending is guaranteed to fire false in a finally block, so it resets even if the fetch throws or the abort signal fires.
Cancelling
The submit callback receives cancel and controller:
cancel()— bail out before thefetchis issued. No callback runs.controller.abort()— cancel an in-flight request. TheAbortErroris silently swallowed.
Server-side
No server changes are needed beyond declaring the action. The same Mochi.page(path, { actions }) definition serves both the no-JS HTML POST flow and the enhanced JSON flow:
import { fail, redirect, success } from 'mochi-framework';
Mochi.page('./Login.svelte', {
actions: {
default: ({ formData, cookies }) => {
const username = String(formData.get('username') ?? '');
if (!username) return fail(400, { error: 'Username required' });
return success({ username });
},
},
});Returning a Response directly from an action bypasses the JSON envelope on enhanced submissions — treat that path as an escape hatch.
deserialize
deserialize(text) decodes a raw MochiEnhanceResult envelope. Use it when rolling your own onsubmit instead of {@attach enhance(...)}:
<script>
import { deserialize } from 'mochi-framework';
async function onsubmit(event: SubmitEvent) {
event.preventDefault();
const form = event.currentTarget as HTMLFormElement;
const response = await fetch(form.action, {
method: 'POST',
headers: { Accept: 'application/json', 'x-mochi-action': 'true' },
body: new URLSearchParams(new FormData(form) as unknown as Record<string, string>),
});
const result = deserialize(await response.text());
// …
}
</script>When to use enhance
Reach for enhance when the action’s outcome should update UI without a navigation flicker — interactive forms, optimistic patterns, inline validation messages. Stick with a plain <form method="POST"> when the action ends in a redirect anyway and the JS bundle isn’t worth shipping.