🍡 mochi

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+. enhance is 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.typeDefault
successform.reset()
failurenothing (provide a callback to update the UI)
redirectwindow.location.assign(result.location)
errorconsole.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 the fetch is issued. No callback runs.
  • controller.abort() — cancel an in-flight request. The AbortError is 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.