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

ConcernOwnerContract
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.