Implementation guide · Explicit ownership
Conservative integration recipes
These recipes keep recovery, backend verification, dirty-form state, and transport timing under explicit ownership. They do not add automatic retry or infer that an interrupted request failed.
Use supplied recovery markup
Choose prompt mode when the application only needs to own the recovery prompt's placement, semantic structure, and copy. The addon can connect author-provided restore and discard buttons; the manual controller is only needed when application code must decide when to query or invoke recovery commands.
<section
data-a11y-form-submission-recovery-restore
aria-labelledby="saved-progress-title"
hidden
>
<h2 id="saved-progress-title">Continue your saved request?</h2>
<p>Only permitted fields from this tab will be restored.</p>
<button
type="button"
data-a11y-form-submission-recovery-restore-confirm
>Restore saved progress</button>
<button
type="button"
data-a11y-form-submission-recovery-restore-discard
>Discard saved progress</button>
</section>
const sessionRecovery = createSessionRecoveryAddon({
formKey: "service-intake",
fieldNames: ["fullName", "email", "request"],
restoreMode: "prompt",
restoreContainer:
"[data-a11y-form-submission-recovery-restore]"
});
The selector is scoped to the form. A current same-path record
reveals the section. Restore applies filtered values, attempts to
clear the record, and announces the localized restore message.
Discard removes the record and announces the localized discard
message. The addon does not replace the supplied children or copy.
On destroy, it removes its listeners and restores the section's
original hidden state.
Verify an unknown outcome before retry
Keep allowRetryAfterUnknownOutcome at its default
false. Attach the submission-reference addon and reveal
an application-owned Check submission status
control after a failure event reports an unknown outcome, or after
the timed transport below records an uncertain timeout reference.
const recovery = createFormSubmissionRecovery(form, {
addons: [createSubmissionReferenceAddon()],
allowRetryAfterUnknownOutcome: false
});
const timeoutReferences = new Set();
let pendingReference = null;
let verifiedMissing = false;
onFormSubmissionRecoveryEvent(
form,
FORM_SUBMISSION_RECOVERY_EVENTS.failed,
(event) => {
const reference = event.detail.submissionReference;
const requiresVerification =
event.detail.unknownOutcome ||
(reference && timeoutReferences.has(reference));
if (!requiresVerification || !reference) return;
pendingReference = reference;
verifiedMissing = false;
checkStatusButton.hidden = false;
verifiedRetryButton.hidden = true;
}
);
checkStatusButton.addEventListener("click", async () => {
const reference = pendingReference;
if (!reference) return;
verifiedMissing = false;
verifiedRetryButton.hidden = true;
let result;
try {
const response = await fetch(
`/api/submissions/${encodeURIComponent(reference)}/status`,
{ credentials: "same-origin" }
);
if (!response.ok || pendingReference !== reference) {
throw new Error("Verification was unsuccessful.");
}
result = await response.json();
} catch {
statusElement.textContent =
"Submission status could not be verified. Do not retry yet.";
return;
}
if (result.status === "received") {
recovery.reset({ clearStatus: false });
checkStatusButton.hidden = true;
pendingReference = null;
timeoutReferences.delete(reference);
statusElement.textContent =
"Your submission was received. Do not submit it again.";
} else if (result.status === "not_received") {
verifiedMissing = true;
timeoutReferences.delete(reference);
verifiedRetryButton.hidden = false;
statusElement.textContent =
"The submission was not received. You may try again.";
} else {
verifiedRetryButton.hidden = true;
statusElement.textContent =
"Submission status is still unknown. Do not retry yet.";
}
});
verifiedRetryButton.addEventListener("click", async () => {
if (!verifiedMissing || !pendingReference) return;
verifiedMissing = false;
checkStatusButton.hidden = true;
verifiedRetryButton.hidden = true;
await recovery.retry({ allowUnknownOutcome: true });
});
The lookup must be authenticated and scoped to the current user. A reference is not a credential. If the backend confirms receipt, direct the user to the canonical result and do not retry. If it authoritatively confirms non-receipt, let the user explicitly retry. If the lookup fails or remains indeterminate, keep retry unavailable.
Separate dirty-form ownership
| Concern | Owner | Contract |
|---|---|---|
| Reload recovery | Session-recovery addon | Saves filtered values and clears only its namespaced record. |
| Unsaved-change warning | Application or dirty-form integration | Stays dirty after local saves, restores, failures, aborts, and unknown outcomes. |
| Clean-on-success | Application coordinator | Marks clean only after an outcome classified as accepted. |
let dirty = false;
function warnBeforeLeaving(event) {
if (!dirty) return;
event.preventDefault();
event.returnValue = "";
}
function setDirty(next) {
if (dirty === next) return;
dirty = next;
if (dirty) {
window.addEventListener("beforeunload", warnBeforeLeaving);
} else {
window.removeEventListener("beforeunload", warnBeforeLeaving);
}
}
form.addEventListener("input", () => setDirty(true));
form.addEventListener("change", () => setDirty(true));
onSessionRecoveryEvent(
form,
SESSION_RECOVERY_EVENTS.restored,
() => setDirty(true)
);
onFormSubmissionRecoveryEvent(
form,
FORM_SUBMISSION_RECOVERY_EVENTS.succeeded,
() => setDirty(false)
);
A session-saved event means values were copied to
browser storage, not accepted by the backend. Discarding a stored
snapshot also does not make current edits clean.
clearOnSuccess clears only the addon's own storage and
never changes another dirty-form tool.
Add a transport timeout
Put timeout policy in a custom transport. Combine its timer with the
package signal so destroy() and reset cleanup still
cancel active work.
const recovery = createFormSubmissionRecovery(form, {
addons: [createSubmissionReferenceAddon()],
async transport(context) {
const timeoutSignal = AbortSignal.timeout(15_000);
const signal = AbortSignal.any([
context.signal,
timeoutSignal
]);
try {
return await fetch("/service-intake", {
method: "POST",
body: context.formData,
headers: context.headers,
signal,
credentials: "same-origin"
});
} catch (error) {
if (timeoutSignal.aborted && !context.signal.aborted) {
const reference =
context.metadata.get("submissionReference");
if (typeof reference === "string") {
timeoutReferences.add(reference);
}
throw new Error("Submission transport timed out.", {
cause: error
});
}
throw error;
}
}
});
Do not set a multipart Content-Type manually when the
body is FormData. The core converts the timeout error
into a network failure without placing the error object in lifecycle
events. It does not set unknownOutcome for this custom
timeout, so the shared timeoutReferences set routes the
failure through the verification flow above.