Validating and Resetting Options Forms
Validate an MV3 options form without inline handlers: constraint validation plus semantic checks, accessible error messaging, safe reset to defaults, and import and export of settings.
Table of Contents
- Root cause: write-on-change removes the natural validation point
- Step 1. Let the platform do the first pass
- Step 2. Add the checks the platform cannot express
- Step 3. Report errors where they are announced
- Step 4. Reset without collateral damage
- Step 5. Offer export and import for real backups
- Cross-browser variation
- Verification
- FAQ
- Related
An options page that writes on every change has no submit button to validate against, so a half-typed number or an impossible interval reaches storage and from there reaches the service worker. Add a reset button and you inherit a second problem: reset must restore defaults without destroying data the user did not mean to lose. This page is part of Options Page Layouts and covers validation and reset that behave under the extension content security policy.
Root cause: write-on-change removes the natural validation point
A form with a submit button validates once, at a moment the user chose. A form that persists as the user types has to validate per field, decide what to do with an invalid value, and keep the stored state coherent throughout — because the service worker may read it at any moment, including halfway through an edit.
Step 1. Let the platform do the first pass
Constraint validation attributes cost nothing, work without JavaScript and give you a free accessible baseline. Use them for shape, then layer semantic checks on top.
1<!-- options.html — constraints in the markup, no inline handlers -->
2<label for="pollMinutes">Check for updates every</label>
3<input id="pollMinutes" name="pollMinutes" type="number"
4 min="15" max="1440" step="1" required
5 aria-describedby="pollMinutes-error">
6<p id="pollMinutes-error" class="field-error" role="alert" hidden></p>
7
8<label for="apiBase">API base URL</label>
9<input id="apiBase" name="apiBase" type="url"
10 pattern="https://.*" required
11 aria-describedby="apiBase-error">
12<p id="apiBase-error" class="field-error" role="alert" hidden></p>
Execution context: Options page markup, under the extension policy — note that there is not a handler attribute anywhere, which is what makes it loadable at all. aria-describedby pointing at an initially hidden paragraph is the pattern that makes an error announced when it appears; creating the element on demand instead means screen readers often miss it.
Step 2. Add the checks the platform cannot express
Ranges and formats are declarative; relationships between fields and domain rules are not.
1// options-validate.js
2export function validateField(name, value, all) {
3 switch (name) {
4 case "pollMinutes": {
5 if (value === "") return { state: "incomplete" };
6 const n = Number(value);
7 if (!Number.isInteger(n)) return { state: "invalid", message: "Enter a whole number of minutes." };
8 if (n < 15) return { state: "invalid", message: "The minimum interval is 15 minutes." };
9 if (n > 1440) return { state: "invalid", message: "The maximum interval is 24 hours." };
10 return { state: "valid", value: n };
11 }
12 case "apiBase": {
13 if (value === "") return { state: "incomplete" };
14 let url;
15 try { url = new URL(value); } catch { return { state: "invalid", message: "Enter a full URL." }; }
16 if (url.protocol !== "https:") return { state: "invalid", message: "The URL must use https." };
17 return { state: "valid", value: url.origin + url.pathname.replace(/\/$/, "") };
18 }
19 case "quietHoursEnd": {
20 // A cross-field rule the markup cannot express.
21 if (value && all.quietHoursStart && value === all.quietHoursStart) {
22 return { state: "invalid", message: "Quiet hours cannot start and end at the same time." };
23 }
24 return { state: "valid", value };
25 }
26 default:
27 return { state: "valid", value };
28 }
29}
Execution context: Options page module. Returning a three-state result rather than a boolean is what lets the caller distinguish “not finished” from “wrong” — an empty required field while the user is still typing should not turn red. Normalising the URL in the valid branch means storage receives one canonical form regardless of trailing slashes, which saves comparing variants later.
Step 3. Report errors where they are announced
An error paragraph that appears and is never announced is invisible to a screen reader user, and a red border alone fails contrast requirements for conveying meaning by colour.
1// options.js
2function showFieldState(input, result) {
3 const error = document.getElementById(`${input.id}-error`);
4
5 if (result.state === "invalid") {
6 input.setAttribute("aria-invalid", "true");
7 error.textContent = result.message;
8 error.hidden = false;
9 return false;
10 }
11
12 input.removeAttribute("aria-invalid");
13 error.hidden = true;
14 error.textContent = "";
15 return result.state === "valid";
16}
17
18for (const input of document.querySelectorAll("input, select")) {
19 input.addEventListener("input", () => {
20 const all = Object.fromEntries(new FormData(document.querySelector("form")).entries());
21 const result = validateField(input.name, input.value, all);
22 if (showFieldState(input, result)) scheduleWrite({ [input.name]: result.value });
23 });
24}
Execution context: Options page renderer. Writing only when showFieldState returns true is what keeps invalid values out of storage entirely — the service worker never sees an interval of 3 minutes, so it needs no defensive clamp of its own. Because the error paragraph already exists with role="alert", setting its text is enough to have it announced.
Step 4. Reset without collateral damage
“Reset to defaults” should restore the settings this page owns and nothing else. chrome.storage.local.clear() is the tempting one-liner and it destroys caches, drafts, migration state and anything else the extension keeps.
1// options.js
2import { DEFAULTS } from "./config-schema.js";
3
4async function resetToDefaults() {
5 // Only the keys this page owns. Never clear().
6 const owned = Object.keys(DEFAULTS);
7 await chrome.storage.sync.set(DEFAULTS);
8
9 // Remove any owned key that no longer has a default, so stale settings do not linger.
10 const stored = await chrome.storage.sync.get(null);
11 const orphans = Object.keys(stored).filter((k) => owned.includes(k) === false && k.startsWith("setting:"));
12 if (orphans.length) await chrome.storage.sync.remove(orphans);
13
14 hydrateForm(DEFAULTS);
15 announce("Settings restored to their defaults.");
16}
Execution context: Options page renderer. Writing the defaults rather than removing the keys means the onChanged listeners in the worker and other surfaces receive a change with a real newValue and can react normally — a removal produces a change with no newValue, which handlers frequently mishandle. The confirmation step is worth adding in front of this: reset is destructive from the user’s point of view even when it is careful from yours.
Step 5. Offer export and import for real backups
Users who have tuned a settings page want to move it to another machine or keep it before an experiment. Export and import are twenty lines and remove the main reason people fear the reset button.
1// options.js
2async function exportSettings() {
3 const settings = await chrome.storage.sync.get(Object.keys(DEFAULTS));
4 const blob = new Blob([JSON.stringify({ v: 1, settings }, null, 2)], { type: "application/json" });
5 const url = URL.createObjectURL(blob);
6 const a = document.createElement("a");
7 a.href = url;
8 a.download = "extension-settings.json";
9 a.click();
10 setTimeout(() => URL.revokeObjectURL(url), 1000);
11}
12
13async function importSettings(file) {
14 const parsed = JSON.parse(await file.text());
15 if (parsed?.v !== 1 || typeof parsed.settings !== "object") throw new Error("unrecognised-file");
16
17 // Run the same validation the form uses — an imported file is untrusted input.
18 const clean = {};
19 for (const [name, value] of Object.entries(parsed.settings)) {
20 if (!(name in DEFAULTS)) continue;
21 const result = validateField(name, String(value), parsed.settings);
22 clean[name] = result.state === "valid" ? result.value : DEFAULTS[name];
23 }
24
25 await chrome.storage.sync.set(clean);
26 hydrateForm(clean);
27}
Execution context: Options page renderer. Running imported values through the same validator as typed ones is the important line — a settings file is a text file a user can edit, so it is untrusted input in exactly the way remote configuration is. Falling back to the default for any field that fails means a partially corrupt file still imports usefully rather than failing wholesale.
Cross-browser variation
- Chrome 120+: constraint validation,
role="alert"announcements and object URLs all behave as on the web.storage.syncwrite limits apply to the import, so import in onesetcall. - Firefox 121+: identical behaviour. Its options page can be embedded in the add-ons manager, where the available width is smaller — validate that error text wraps rather than overflowing.
- Safari 17+: constraint validation is supported; downloads from an extension page may prompt differently, so treat export as best-effort and offer copy-to-clipboard as an alternative.
- All engines: inline event handlers are blocked, so every listener must be attached from an external file. This is the constraint that shapes the whole page.
Verification
- Type
1into the interval field. It should report the minimum without writing, and the worker’s schedule should be unchanged. - Clear the field entirely. It should not turn red, and the last good value should remain in storage.
- Reset to defaults with an unrelated cache key present in local storage, and confirm the cache is untouched.
- Export, edit the file to contain an invalid interval, and import it. The field should fall back to the default rather than the file’s value.
FAQ
Should an invalid value be written and clamped later?
No. Clamping in the consumer means the stored state and the displayed state disagree, and every consumer has to repeat the clamp. Refusing the write keeps one definition of valid.
How do I mark a field invalid accessibly?
aria-invalid on the control plus an error element referenced by aria-describedby, with role="alert" so it is announced when it appears. Colour alone is not sufficient.
Is clear() ever the right reset?
Only for a deliberate “erase everything this extension stores” action, presented as such and confirmed. It is not the same operation as restoring default settings.
Where should settings live, sync or local?
Sync for anything the user would expect on another device, which is most settings. Device-specific values — a local path, a window size — belong in local storage.
Related
- Embedding options in the popup — the same form in a smaller surface.
- Syncing options form state with chrome storage — the write path this page validates.
- Building a tabbed options page layout — where the fields live.
- Options Page Layouts — the guide this page belongs to.