How to test form data is kept after a failed submit

Fill every field, force the failure, then read what each control holds with node probe.mjs server. A handler that echoed one field returned the other five empty, including a file the server had already received in full. The browser's own back navigation restored five of the six, which is why one tester cannot reproduce what another reports.

Why check this

A submit that fails is the moment a form loses typed work. Four mechanisms produce that loss and they look identical to the person typing: the handler did not echo the value, the page never navigated, the browser refilled the fields from session history, or a saved draft put something back. Run it when a form gains a server-side rule, and after any change to the submit handler or cache headers.

The failure it prevents is a ticket nobody can reproduce. One tester reaches the error page by pressing back and sees a full form, another submits and sees an empty one. The route is what differed, and it belongs in the report.

The file field is the sharpest case: a person who re-uploads is redoing work the form already took and discarded.

Prerequisites

server.mjs, the target: six kinds of field, one rule, one echoed field.

// server.mjs : one form, six kinds of field, and a handler that echoes one of them.
import { createServer } from 'node:http';

const PORT = 8934;
const accepted = [];

const CLIENT_GUARD = `
document.querySelector('#f').addEventListener('submit', (e) => {
  const name = document.querySelector('#fullname');
  if (name.value.length < 5) {
    e.preventDefault();
    document.querySelector('#err').textContent = 'Full name needs 5 characters.';
  }
});`;

const DRAFT = `
const KEY = 'draft:signup';
const form = document.querySelector('#f');
const save = () => {
  const d = {};
  for (const el of form.elements) {
    if (!el.name) continue;
    if (el.type === 'checkbox' || el.type === 'radio') { if (el.checked) d[el.name] = el.value; }
    else d[el.name] = el.value;
  }
  sessionStorage.setItem(KEY, JSON.stringify(d));
};
form.addEventListener('input', save);
form.addEventListener('change', save);
const saved = sessionStorage.getItem(KEY);
if (saved) {
  const d = JSON.parse(saved);
  for (const el of form.elements) {
    if (!el.name || !(el.name in d)) continue;
    if (el.type === 'checkbox' || el.type === 'radio') el.checked = el.value === d[el.name];
    else if (el.type !== 'file') el.value = d[el.name];
  }
}`;

const page = (opt, echo = {}, error = '') => `<!doctype html><html lang="en"><meta charset="utf-8">
<title>signup</title>
<p id="err">${error}</p>
<form id="f" method="post" action="/submit?${opt}" enctype="multipart/form-data">
  <input id="fullname" name="fullname" type="text" value="${echo.fullname ?? ''}">
  <select id="plan" name="plan">
    <option value="free">Free</option><option value="pro">Pro</option><option value="team">Team</option>
  </select>
  <input id="terms" name="terms" type="checkbox" value="yes">
  <input id="email" name="contact" type="radio" value="email">
  <input id="phone" name="contact" type="radio" value="phone">
  <input id="password" name="password" type="password">
  <input id="avatar" name="avatar" type="file">
  <button id="go" type="submit">Create account</button>
</form>
<script>
${opt.includes('guard=client') ? CLIENT_GUARD : ''}
${opt.includes('draft=on') ? DRAFT : ''}
</script>`;

function parseMultipart(buf, boundary) {
  const out = {};
  for (const part of buf.toString('binary').split(`--${boundary}`)) {
    const i = part.indexOf('\r\n\r\n');
    if (i === -1) continue;
    const head = part.slice(0, i);
    const name = /name="([^"]*)"/.exec(head);
    if (!name) continue;
    const file = /filename="([^"]*)"/.exec(head);
    const body = part.slice(i + 4).replace(/\r\n$/, '');
    out[name[1]] = file ? { filename: file[1], bytes: Buffer.byteLength(body, 'binary') } : body;
  }
  return out;
}

const headers = (opt) => ({
  'content-type': 'text/html; charset=utf-8',
  'cache-control': opt.includes('cache=store') ? 'max-age=60' : 'no-store',
});

createServer((req, res) => {
  const url = new URL(req.url, 'http://127.0.0.1');
  const opt = url.searchParams.toString();
  if (req.method === 'POST' && url.pathname === '/submit') {
    const chunks = [];
    req.on('data', (c) => chunks.push(c));
    req.on('end', () => {
      const boundary = /boundary=(.+)$/.exec(req.headers['content-type'] ?? '')?.[1] ?? '';
      const f = parseMultipart(Buffer.concat(chunks), boundary);
      console.log(`POST /submit received ${JSON.stringify(f)}`);
      if ((f.fullname ?? '').length < 5) {
        // The whole defect in one line: one field is echoed, the other five are not.
        res.writeHead(422, headers(opt));
        return res.end(page(opt, { fullname: f.fullname }, 'Full name needs 5 characters.'));
      }
      accepted.push(f);
      res.writeHead(200, headers(opt));
      return res.end(`<!doctype html><html lang="en"><meta charset="utf-8"><title>done</title><p id="ok">account ${accepted.length} created</p>`);
    });
    return;
  }
  if (url.pathname === '/other') {
    res.writeHead(200, headers(opt));
    return res.end('<!doctype html><html lang="en"><meta charset="utf-8"><title>other</title><p>another page</p>');
  }
  if (url.pathname === '/records') {
    res.writeHead(200, { 'content-type': 'application/json' });
    return res.end(JSON.stringify({ accepted }) + '\n');
  }
  res.writeHead(200, headers(opt));
  res.end(page(opt));
}).listen(PORT, '127.0.0.1', () => console.log(`form target on 127.0.0.1:${PORT}`));

probe.mjs, the driver. Its upload is printf 'avatar bytes' > avatar.txt, 12 bytes.

// probe.mjs <case> : fills every field, then reports what each one holds afterwards.
// cases: server | client | back-no-store | back-store | reload-no-store | reload-store | file | draft
import { launch } from 'puppeteer-core';
import { fileURLToPath } from 'node:url';

const CHROME = process.env.CHROME ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe';
const BASE = 'http://127.0.0.1:8934';
const AVATAR = fileURLToPath(new URL('avatar.txt', import.meta.url));
const kase = process.argv[2] ?? 'server';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

const TYPED = 'fullname="Ann" plan=team terms=checked contact=phone password="hunter2hunter2" avatar=avatar.txt';

async function fill(page) {
  await page.type('#fullname', 'Ann');
  await page.select('#plan', 'team');
  await page.click('#terms');
  await page.click('#phone');
  await page.type('#password', 'hunter2hunter2');
  await (await page.$('#avatar')).uploadFile(AVATAR);
  console.log(`typed: ${TYPED}`);
}

const read = (page) => page.evaluate(() => [...document.querySelectorAll('#f [name]')].map((el) => ({
  field: el.name,
  type: el.type,
  value: el.value,
  checked: el.type === 'checkbox' || el.type === 'radio' ? String(el.checked) : '-',
  files: el.type === 'file' ? String(el.files.length) : '-',
})));

function table(rows) {
  const cols = ['field', 'type', 'value', 'checked', 'files'];
  const w = cols.map((c) => Math.max(c.length, ...rows.map((r) => String(r[c]).length)));
  const line = (cells) => cells.map((c, i) => String(c).padEnd(w[i])).join('  ').trimEnd();
  console.log(line(cols));
  for (const r of rows) console.log(line(cols.map((c) => r[c])));
}

const browser = await launch({ executablePath: CHROME, headless: true, args: ['--lang=en-US'] });
const page = await browser.newPage();
let docs = 0;
page.on('response', (r) => { if (r.request().resourceType() === 'document') docs += 1; });

console.log(`case ${kase}`);

if (kase === 'server') {
  await page.goto(`${BASE}/`, { waitUntil: 'load' });
  await fill(page);
  const [res] = await Promise.all([page.waitForNavigation({ waitUntil: 'load' }), page.click('#go')]);
  console.log(`submit -> HTTP ${res.status()} at ${new URL(page.url()).pathname}, error "${await page.$eval('#err', (e) => e.textContent)}"`);
  table(await read(page));
} else if (kase === 'client') {
  await page.goto(`${BASE}/?guard=client`, { waitUntil: 'load' });
  await fill(page);
  const before = docs;
  await page.click('#go');
  await sleep(500);
  console.log(`submit -> ${docs - before} document response(s), still at ${new URL(page.url()).pathname}, error "${await page.$eval('#err', (e) => e.textContent)}"`);
  table(await read(page));
} else if (kase.startsWith('back-') || kase.startsWith('reload-')) {
  const cache = kase.endsWith('-store') && !kase.endsWith('no-store') ? 'store' : 'no-store';
  await page.goto(`${BASE}/?cache=${cache}`, { waitUntil: 'load' });
  await fill(page);
  const before = docs;
  if (kase.startsWith('back-')) {
    await page.goto(`${BASE}/other?cache=${cache}`, { waitUntil: 'load' });
    await page.goBack();
  } else {
    await page.reload();
  }
  await sleep(800);
  console.log(`cache-control: ${cache}, ${docs - before} document response(s) during the action, back at ${new URL(page.url()).pathname}`);
  table(await read(page));
} else if (kase === 'file') {
  await page.goto(`${BASE}/`, { waitUntil: 'load' });
  const r = await page.evaluate(() => {
    const out = {};
    const host = document.createElement('div');
    host.innerHTML = '<input type="file" value="avatar.txt">';
    const parsed = host.querySelector('input');
    out['markup value="avatar.txt"'] = `value ${JSON.stringify(parsed.value)}, files ${parsed.files.length}`;
    const el = document.querySelector('#avatar');
    try { el.value = 'avatar.txt'; out['el.value = "avatar.txt"'] = 'no error'; }
    catch (e) { out['el.value = "avatar.txt"'] = `${e.name}: ${e.message}`; }
    try { el.value = ''; out["el.value = ''"] = `no error, files ${el.files.length}`; }
    catch (e) { out["el.value = ''"] = `${e.name}: ${e.message}`; }
    return out;
  });
  for (const [k, v] of Object.entries(r)) console.log(`${k} -> ${v}`);
} else if (kase === 'draft') {
  await page.goto(`${BASE}/?draft=on`, { waitUntil: 'load' });
  const empty = await page.evaluate(() => sessionStorage.getItem('draft:signup'));
  console.log(`before typing, sessionStorage draft:signup = ${empty}`);
  await fill(page);
  console.log(`after typing,  sessionStorage draft:signup = ${await page.evaluate(() => sessionStorage.getItem('draft:signup'))}`);
  await Promise.all([page.waitForNavigation({ waitUntil: 'load' }), page.click('#go')]);
  console.log(`failed submit -> back on the form, fields now:`);
  table(await read(page));
  await page.$eval('#fullname', (e) => { e.value = ''; });
  await page.type('#fullname', 'Annabel');
  await (await page.$('#avatar')).uploadFile(AVATAR);
  await Promise.all([page.waitForNavigation({ waitUntil: 'load' }), page.click('#go')]);
  console.log(`successful submit -> ${await page.$eval('#ok', (e) => e.textContent)}`);
  console.log(`after success, sessionStorage draft:signup = ${await page.evaluate(() => sessionStorage.getItem('draft:signup'))}`);
  await page.goto(`${BASE}/?draft=on`, { waitUntil: 'load' });
  console.log(`a later visit to the empty form holds:`);
  table(await read(page));
}

console.log(`chrome ${await browser.version()}`);
await browser.close();

Steps

  1. Step 1.

    Start the target in its own shell.

    node server.mjs
    
    form target on 127.0.0.1:8934

    Stop it later by the PID that owns port 8934, never by image name.

  2. Step 2.

    Fill every field, submit, and read the form the error response rendered.

    node probe.mjs server
    
    case server
    typed: fullname="Ann" plan=team terms=checked contact=phone password="hunter2hunter2" avatar=avatar.txt
    submit -> HTTP 422 at /submit, error "Full name needs 5 characters."
    field     type        value  checked  files
    fullname  text        Ann    -        -
    plan      select-one  free   -        -
    terms     checkbox    yes    false    -
    contact   radio       email  false    -
    contact   radio       phone  false    -
    password  password           -        -
    avatar    file               -        0
    chrome Chrome/152.0.7977.76

    One field of six survived: the select fell back to its first option, the checkbox and both radios read false, the password and file inputs empty.

  3. Step 3.

    Read what the server held as it rendered that page.

    grep 'POST /submit' server.log | head -1
    
    POST /submit received {"fullname":"Ann","plan":"team","terms":"yes","contact":"phone","password":"hunter2hunter2","avatar":{"filename":"avatar.txt","bytes":12}}

    Every value arrived, the upload included, and five were dropped on the way out.

  4. Step 4.

    Ask the browser whether the server could have restored the file.

    node probe.mjs file
    
    case file
    markup value="avatar.txt" -> value "", files 0
    el.value = "avatar.txt" -> InvalidStateError: Failed to set the 'value' property on 'HTMLInputElement': This input element accepts a filename, which may only be programmatically set to the empty string.
    el.value = '' -> no error, files 0
    chrome Chrome/152.0.7977.76

    A value attribute parses to the empty string, and the assignment raises InvalidStateError. No server-side echo refills that control.

  5. Step 5.

    Run the same form with a handler that calls preventDefault() on failure.

    node probe.mjs client
    
    case client
    typed: fullname="Ann" plan=team terms=checked contact=phone password="hunter2hunter2" avatar=avatar.txt
    submit -> 0 document response(s), still at /, error "Full name needs 5 characters."
    field     type        value                   checked  files
    fullname  text        Ann                     -        -
    plan      select-one  team                    -        -
    terms     checkbox    yes                     true     -
    contact   radio       email                   false    -
    contact   radio       phone                   true     -
    password  password    hunter2hunter2          -        -
    avatar    file        C:\fakepath\avatar.txt  -        1
    chrome Chrome/152.0.7977.76

    Zero document responses. Nothing navigated, so nothing was restored: all six fields hold what was typed.

  6. Step 6.

    Navigate away from the filled form and press back, under cache-control: no-store.

    node probe.mjs back-no-store
    
    case back-no-store
    typed: fullname="Ann" plan=team terms=checked contact=phone password="hunter2hunter2" avatar=avatar.txt
    cache-control: no-store, 2 document response(s) during the action, back at /
    field     type        value                   checked  files
    fullname  text        Ann                     -        -
    plan      select-one  team                    -        -
    terms     checkbox    yes                     true     -
    contact   radio       email                   false    -
    contact   radio       phone                   true     -
    password  password                            -        -
    avatar    file        C:\fakepath\avatar.txt  -        1
    chrome Chrome/152.0.7977.76

    The document was fetched again and the values came back anyway, from session history rather than the markup. The password is the one field Chrome left empty.

  7. Step 7.

    Repeat with the same page served as cache-control: max-age=60.

    node probe.mjs back-store
    
    case back-store
    typed: fullname="Ann" plan=team terms=checked contact=phone password="hunter2hunter2" avatar=avatar.txt
    cache-control: store, 1 document response(s) during the action, back at /
    field     type        value                   checked  files
    fullname  text        Ann                     -        -
    plan      select-one  team                    -        -
    terms     checkbox    yes                     true     -
    contact   radio       email                   false    -
    contact   radio       phone                   true     -
    password  password    hunter2hunter2          -        -
    avatar    file        C:\fakepath\avatar.txt  -        1
    chrome Chrome/152.0.7977.76

    One document response instead of two, and the password came back too. A cache header decided whether a password sits in a field after the back button.

  8. Step 8.

    Reload the filled form instead of navigating back.

    node probe.mjs reload-no-store
    
    case reload-no-store
    typed: fullname="Ann" plan=team terms=checked contact=phone password="hunter2hunter2" avatar=avatar.txt
    cache-control: no-store, 1 document response(s) during the action, back at /
    field     type        value  checked  files
    fullname  text               -        -
    plan      select-one  free   -        -
    terms     checkbox    yes    false    -
    contact   radio       email  false    -
    contact   radio       phone  false    -
    password  password           -        -
    avatar    file               -        0
    chrome Chrome/152.0.7977.76

    Nothing was restored, and node probe.mjs reload-store printed the same table. This is a protocol reload over the DevTools connection: the harness cannot press Chrome's own reload control, so the step says nothing about it.

  9. Step 9.

    Add a draft in sessionStorage and follow it through a failure and a success.

    node probe.mjs draft
    
    case draft
    before typing, sessionStorage draft:signup = null
    typed: fullname="Ann" plan=team terms=checked contact=phone password="hunter2hunter2" avatar=avatar.txt
    after typing,  sessionStorage draft:signup = {"fullname":"Ann","plan":"team","terms":"yes","contact":"phone","password":"hunter2hunter2","avatar":"C:\\fakepath\\avatar.txt"}
    failed submit -> back on the form, fields now:
    field     type        value           checked  files
    fullname  text        Ann             -        -
    plan      select-one  team            -        -
    terms     checkbox    yes             true     -
    contact   radio       email           false    -
    contact   radio       phone           true     -
    password  password    hunter2hunter2  -        -
    avatar    file                        -        0
    successful submit -> account 1 created
    after success, sessionStorage draft:signup = {"fullname":"Annabel","plan":"team","terms":"yes","contact":"phone","password":"hunter2hunter2","avatar":"C:\\fakepath\\avatar.txt"}
    a later visit to the empty form holds:
    field     type        value           checked  files
    fullname  text        Annabel         -        -
    plan      select-one  team            -        -
    terms     checkbox    yes             true     -
    contact   radio       email           false    -
    contact   radio       phone           true     -
    password  password    hunter2hunter2  -        -
    avatar    file                        -        0
    chrome Chrome/152.0.7977.76

    The draft is written on every keystroke, so it holds the password before anything is sent. It refilled the error page, and outlived the success: a later visit came back holding an account already created.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | One field filled and the rest empty after a failed submit | The handler echoed that one value and no other | Raise one defect naming the fields, not one saying the form clears. | | The server log holds values the rendered form does not | The loss is in the response, not in the request | Fix it in the template. The data is already there. | | files 0 with a filename in the server log | No markup can put an upload back | Keep the file server side and show its name, or state that a retry re-uploads. | | Zero document responses and every field intact | preventDefault() stopped the navigation | This path proves nothing about the server round trip. Run step 2 as well. | | Fields refilled after the back button | Chrome restored them from session history, not from your markup | Record how you reached the page. How to test double form submission has the same split. | | The password present after back on one page and absent on another | The cache header on the form page differed | Decide which behaviour you want, then assert on it. | | A draft that refills a form after a successful submit | Nothing cleared the key on the success path | Clear it where the success is handled, and check what the key holds. |

Common mistakes

Sign: The form comes back full during testing, so state retention is signed off.Cause: Step 6 and step 7 came back full and the markup had nothing to do with it. Chrome refilled the controls from session history after a back navigation, while step 2, the submit a person actually makes, returned five of six fields empty.
Sign: A defect is reported, cannot be reproduced, and is closed as not a bug.Cause: Three routes to the same error page gave three results here: the submit kept one field, the back button kept five, the reload kept none. The route is the reproduction step, and a report without it is a coin toss.
Sign: The upload field is listed with the others in one bug about cleared fields.Cause: Step 4 shows why it belongs in its own defect. A value attribute on a file input parses to the empty string and the assignment raises InvalidStateError, so no server response can refill it. The fix is to keep the uploaded bytes and stop asking for them again.
Sign: A draft feature is added, the retention test passes, and passwords turn up in a shared browser.Cause: The draft in step 9 was written on every input event and held hunter2hunter2 in sessionStorage before any submit. It also survived the successful submit and refilled a later visit, so the next person at that tab opened a form holding the previous account.

What to check next

FAQ

How do you retain values in input fields when a submit fails?

Echo each value into the markup the error response renders, field by field. A checkbox needs checked, a select needs selected, and the file input cannot be echoed at all.

Does the browser keep form data when the user presses back?

Chrome did here, on both cache settings, for five of six fields. That is session history restoration, not your markup.

Why did the password come back on one run and not on the other?

The cache header on the form page: no-store in step 6, max-age=60 in step 7. Same actions, same markup.

Should a draft be saved to localStorage or sessionStorage?

Either outlives the submit unless something removes the key. The draft in step 9 refilled a fresh form after an account was created, password included.

Verified

Verified by Maks Vernynode 22.23.2Chrome 152.0.7977.76puppeteer-core 25.10.0

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate10 minpublished updated Maks Verny