Thesis: /#form?utm=outreach&t=abc is not a query string. Everything after # is a fragment. The browser does not send it to the server, and location.search stays empty. Personalized links must be /?utm=outreach&t=abc#form.
What we shipped first
The intake form sits near the bottom of the page, anchored as #intake-form. We scrolled to it, copied the address bar, and pasted that URL into the campaign sheet. The bar already ended in the fragment. We appended ?utm_source=outreach&t=token after it.
The sheet looked ordered. The form id came first. The browser still treated the question mark as part of the fragment, not as a search string. The document request had no query string.
- Native scroll looks up an element whose id is the whole fragment. Ours was intake-form?utm_source=outreach&t=token. No element has that id, so the page stayed at the top.
- location.search was empty. Company name, contact, and the prefill token never reached the form.
Empty fields look like a form bug. They were not. The parameters never left the address bar.
The working shape
Search first, fragment last: /?utm_source=outreach&t=token#intake-form. Generate every outreach link from one helper. Do not copy a scrolled address bar into the sheet.
function formUrl(query) {
const qs = query.toString();
return '/?' + qs + '#intake-form';
}
Concatenating the hash first and appending ? afterward is the bug, written as a helper. The test is the URL constructor: search is non-empty, and hash is #intake-form with no question mark inside it.
Checklist
- Write links as /?key=value#id, never /#id?key=value.
- Assert in a test that the generated href’s ? appears before its #.
- Read prefill from location.search only. Do not parse a homemade query out of location.hash unless the whole app is a hash router.
- Click the link from chat or mail, not only from the address bar, before you call the form prefilled.
How we deliver: methodology.
Engineering commentary only — not audit, legal, or certification advice.