-
Notifications
You must be signed in to change notification settings - Fork 10
docs: improve connectivity #182
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,54 @@ | ||
| # Preventing Multiple Form Submissions | ||
| # Preventing Duplicate Form Submissions | ||
|
|
||
| This guide will discuss disabling buttons & forms after the first submission. | ||
| A native form sends an HTTP request and follows the server's response. The browser may send the same request more than once because of repeated submissions, retries, or multiple open tabs, so correctness cannot depend on disabling a button. The server should make repeated submissions safe. | ||
|
|
||
| ## Submission Keys | ||
|
|
||
| Include a unique submission key in the rendered form. Because the page renders on the server, the form works without browser JavaScript. | ||
|
|
||
| ```marko | ||
| /* src/routes/rsvp/+page.marko */ | ||
| <const/submissionId=crypto.randomUUID()> | ||
|
|
||
| <form method="POST" action="/rsvp"> | ||
| <input type="hidden" name="submissionId" value=submissionId> | ||
|
|
||
| <label for="guests">Number of guests</label> | ||
| <input id="guests" name="guests" type="number" min="1" required> | ||
|
|
||
| <button type="submit">RSVP</button> | ||
| </form> | ||
| ``` | ||
|
|
||
| The handler validates the submitted fields and passes the key to the persistence layer. Repeating the request returns the record created by the first request instead of creating another one. | ||
|
|
||
| ```ts | ||
| /* src/routes/rsvp/+handler.ts */ | ||
| export async function POST(context) { | ||
| const data = await context.request.formData(); | ||
| const submissionId = data.get("submissionId"); | ||
| const guestsValue = data.get("guests"); | ||
| const guests = typeof guestsValue === "string" ? Number(guestsValue) : NaN; | ||
|
|
||
| if ( | ||
| typeof submissionId !== "string" || | ||
| !submissionId || | ||
| !Number.isInteger(guests) || | ||
| guests < 1 | ||
| ) { | ||
| return new Response("Invalid submission.", { status: 400 }); | ||
| } | ||
|
|
||
| const rsvp = await saveRsvpOnce({ submissionId, guests }); | ||
| return context.redirect(`/rsvp/${rsvp.id}`, 303); | ||
| } | ||
| ``` | ||
|
|
||
| > [!WARNING] | ||
| > `saveRsvpOnce` must enforce uniqueness for the submission key in the same database transaction that creates the record. Checking for an existing key and inserting in separate operations leaves a race condition. | ||
|
Comment on lines
+42
to
+48
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Require database-enforced uniqueness explicitly. A check-then-insert operation can still race even when both statements share a transaction. Specify a unique index or constraint on 🤖 Prompt for AI Agents |
||
|
|
||
| ## Redirecting | ||
|
|
||
| After processing the `POST`, the handler returns a `303` redirect to a `GET` page. This [Post/Redirect/Get pattern](https://en.wikipedia.org/wiki/Post/Redirect/Get) means refreshing the resulting page repeats only the `GET`, not the form submission. The submission key still protects against duplicate requests that reach the server before the redirect. | ||
|
|
||
| For the complete native form flow, see the [Forms guide](./forms.md). | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,149 @@ | ||
| # Forms | ||
|
|
||
| Marko renders HTML, and HTML forms work without any JavaScript. Starting from a native `<form>` keeps submissions functional before scripts load, and Marko's reactivity can then layer on richer behavior where it helps. | ||
|
|
||
| ## Server Submission | ||
|
|
||
| In a [Marko Run](../marko-run/getting-started.md) app, a form posts to a route, and a [`+handler`](../marko-run/file-based-routing.md#handler) beside the page receives the submission. The handler reads the submitted fields with the standard [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData) API and redirects when finished. | ||
|
|
||
| ```marko | ||
| /* src/routes/feedback/+page.marko */ | ||
| <h1>Send Feedback</h1> | ||
| <form method="POST"> | ||
| <label for="message">Message</label> | ||
| <textarea id="message" name="message" required></textarea> | ||
|
|
||
| <button type="submit">Send</button> | ||
| </form> | ||
| ``` | ||
|
|
||
| ```ts | ||
| /* src/routes/feedback/+handler.ts */ | ||
| export async function POST(context) { | ||
| const data = await context.request.formData(); | ||
| const message = data.get("message"); | ||
|
|
||
| if (typeof message !== "string" || !message.trim()) { | ||
| return new Response("A message is required.", { status: 400 }); | ||
| } | ||
|
|
||
| await saveFeedback(message); | ||
| return context.redirect("/feedback/thanks", 303); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
| ``` | ||
|
|
||
| `GET` requests continue to the page as usual, while the `POST` export handles submissions from the form above. | ||
|
|
||
| > [!NOTE] | ||
| > Redirecting after a successful `POST` (the [Post/Redirect/Get pattern](https://en.wikipedia.org/wiki/Post/Redirect/Get)) prevents a page refresh from resubmitting the form. See [Preventing Duplicate Form Submissions](./duplicate-form-submissions.md) for making repeated requests safe on the server. | ||
|
|
||
| ## Validating | ||
|
|
||
| Native [validation attributes](https://developer.mozilla.org/en-US/docs/Learn_web_development/Extensions/Forms/Form_validation) such as `required`, `min`, and `pattern` block invalid submissions in the browser without any additional code. | ||
|
|
||
| ```marko | ||
| <form method="POST"> | ||
| <label for="age">Age</label> | ||
| <input id="age" name="age" type="number" min="18" required> | ||
|
|
||
| <button type="submit">Continue</button> | ||
| </form> | ||
| ``` | ||
|
|
||
| > [!WARNING] | ||
| > Browser validation is a convenience, not a boundary. Requests can be constructed without the form, so handlers must validate submitted values again on the server. | ||
|
|
||
| ## Reacting to Input | ||
|
|
||
| Binding form controls to [tag variables](../reference/language.md#tag-variables) enables live behavior such as previews, character counts, or dependent fields. The [change handler shorthand](../reference/language.md#shorthand-change-handlers-two-way-binding) (`:=`) keeps a variable in sync with a control. | ||
|
|
||
| ```marko | ||
| <let/message=""> | ||
|
|
||
| <form method="POST"> | ||
| <label for="message">Message</label> | ||
| <textarea id="message" name="message" maxlength="280" value:=message/> | ||
| <p>${280 - message.length} characters left</p> | ||
|
|
||
| <button type="submit">Send</button> | ||
| </form> | ||
| ``` | ||
|
|
||
| Because the form still posts natively, this enhancement degrades gracefully: without JavaScript the character counter stays static, but the form submits all the same. | ||
|
|
||
| > [!WARNING] | ||
| > Deriving `disabled` on the submit button from bound state (for example, disabling it until a field is filled in) renders the button disabled in the server HTML, locking out visitors whose JavaScript has not loaded. Prefer [validation attributes](#validating), which the browser enforces on its own. | ||
|
|
||
| ## Binding Controls | ||
|
|
||
| Every stateful form control has a [`Change` handler](../reference/native-tag.md#change-handlers) in Marko, so the `:=` shorthand from the previous section is not limited to text. Radio groups and checkboxes bind through the [`checkedValue` attribute](../reference/native-tag.md#input-typeradio-and-input-typecheckbox), which holds a string for radios and an array for checkbox groups, and `<select>` binds through its [enhanced `value` attribute](../reference/native-tag.md#select). | ||
|
|
||
| ```marko | ||
| <let/format="html"> | ||
| <let/topics=[]> | ||
| <let/frequency="weekly"> | ||
|
|
||
| <form method="POST" action="/newsletter"> | ||
| <fieldset> | ||
| <legend>Format</legend> | ||
| <label><input type="radio" name="format" value="html" checkedValue:=format> HTML</label> | ||
| <label><input type="radio" name="format" value="text" checkedValue:=format> Plain text</label> | ||
| </fieldset> | ||
|
|
||
| <fieldset> | ||
| <legend>Topics</legend> | ||
| <label><input type="checkbox" name="topics" value="releases" checkedValue:=topics> Releases</label> | ||
| <label><input type="checkbox" name="topics" value="community" checkedValue:=topics> Community</label> | ||
| </fieldset> | ||
|
|
||
| <label for="frequency">Frequency</label> | ||
| <select id="frequency" name="frequency" value:=frequency> | ||
| <option value="daily">Daily</option> | ||
| <option value="weekly">Weekly</option> | ||
| <option value="monthly">Monthly</option> | ||
| </select> | ||
|
|
||
| <p>${topics.length} topic${topics.length === 1 ? "" : "s"}, delivered ${frequency}.</p> | ||
|
|
||
| <button type="submit">Subscribe</button> | ||
| </form> | ||
| ``` | ||
|
|
||
| Successful, named controls still submit their values natively; the bound variables exist so the rest of the template can react, as the summary line does here. | ||
|
|
||
| > [!CAUTION] | ||
| > `value` bindings report strings. Binding a numeric input directly (`value:=quantity`) turns the variable into a string on the first edit. In contrast, `checked` bindings produce booleans, while `checkedValue` may produce an array for checkbox groups. Add a [refining function](../reference/language.md#refining-function) to convert each `value` change before it is assigned: | ||
| > | ||
| > ```marko | ||
| > <let/quantity=1> | ||
| > | ||
| > <input type="number" name="quantity" min="1" value:parseFloat:=quantity> | ||
| > ``` | ||
|
|
||
| ## Reusable Fields | ||
|
|
||
| Repeated label-and-input markup can move into a [custom tag](../reference/custom-tag.md#relative-custom-tags), discovered from a `tags/` directory. The [`<id>` tag](../reference/core-tag.md#id) generates a unique id per instance to associate the label with its control, and binding the native input to `input.value` forwards both the value and its change handler to the parent, so the tag is bound with `:=` exactly like a native control. | ||
|
|
||
| ```marko | ||
| /* tags/labeled-input.marko */ | ||
| <id/fieldId> | ||
|
|
||
| <div class="field"> | ||
| <label for=fieldId>${input.label}</label> | ||
| <input id=fieldId name=input.name value:=input.value> | ||
| </div> | ||
| ``` | ||
|
|
||
| ```marko | ||
| /* profile-form.marko */ | ||
| <let/displayName=""> | ||
|
|
||
| <form method="POST"> | ||
| <labeled-input label="Display name" name="displayName" value:=displayName/> | ||
| <p hidden=!displayName>Previewing as ${displayName}</p> | ||
|
|
||
| <button type="submit">Save</button> | ||
| </form> | ||
| ``` | ||
|
|
||
| Components that hold their own state can offer the same interface by making that state controllable; [Controllable Components](../explanation/controllable-components.md) covers the pattern in depth. | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Do not claim that per-render keys protect multiple tabs.
Each rendered tab receives a new
submissionId, so two tabs can create two RSVP records. Limit this claim to retries and repeated submissions of the same rendered form, or document a stable operation-level key when cross-tab deduplication is required.🤖 Prompt for AI Agents