Feat: form-prefill-annotate rule for preselected form controls (#121)#187
Conversation
Annotate-first sibling of `checkout-checkbox-sanitize` and `cart-addon-annotate`. On checkout URLs, prepend a visible chip near text/email/tel/number inputs whose live `.value` is non-empty, `<select>` elements whose `selectedIndex !== 0`, and radio groups whose currently- checked option is server- or framework-rendered. Values are not changed; the agent reads the chip in its DOM snapshot and decides whether to overwrite. Required-selection radio groups stay submittable. FP control: URL gate, recognized-`autocomplete`-token allowlist (skip text inputs whose autocomplete names a legitimate browser-autofill target like `name`, `email`, `street-address`), geo-select skiplist (country/state/region/currency/etc.), focused-control + group-focused skip via a `focusin` listener (cooperates with both browser autofill and mid-fill user interaction), per-form chip cap, visible-only filter. Rule ships default-on as Experimental — action is annotation-only so a misfire is at worst an extra chip on a field. Issue #121 also proposed a sanitize companion for `<input type=hidden>` affiliate / UTM / promo metadata. That arm is split into a follow-up because its toggle / FP profile differs (hidden inputs never reach the agent's snapshot, so annotation can't carry the signal — clearing is the only useful action). Future-work bullets in docs/rules.md cover the hidden-input split, including-value-in-chip, synthetic-blur on annotate, and sanitize-mode for `<select>` defaults on sneaking-prone fields. Tests cover all three control kinds, the autofill-token gate, the geo- select gate, focused-control skip, chip cap, URL gate, idempotency, and lazy-loaded subtrees. Property tests assert autocomplete-allowlist disjointness, geo-select coverage across name/id/aria, `<select>` first- option invariant, and idempotency under random forms. Docs updated under Dark patterns → Preselection; demo Checkout page grows a Shipping & contact form section so the rule has live targets. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
3 issues found.
About Unblocked
Unblocked has been set up to automatically review your team's pull requests to identify genuine bugs and issues.
📖 Documentation — Learn more in our docs.
💬 Ask questions — Mention @unblocked to request a review or summary, or ask follow-up questions.
👍 Give feedback — React to comments with 👍 or 👎 to help us improve.
⚙️ Customize — Adjust settings in your preferences.
| // `autocomplete="off"` on a pre-filled field is the most interesting | ||
| // case — the site disabled browser autofill yet still served a value. | ||
| // Don't skip it just because the attribute was set; fall through. | ||
| isExplicitAutofillOff(input); |
There was a problem hiding this comment.
isExplicitAutofillOff(input) is called as a bare expression statement — its boolean return value is not used and the function has no side effects. This looks like leftover scaffolding (perhaps intended to gate additional logic or logging). As-is it's a no-op that costs a redundant getAttribute + split + filter on every text-input candidate.
Remove the call or assign/use its result:
| isExplicitAutofillOff(input); | |
| // `autocomplete="off"` on a pre-filled field is the most interesting | |
| // case — the site disabled browser autofill yet still served a value. | |
| // Don't skip it just because the attribute was set; fall through. |
There was a problem hiding this comment.
Fixed in 1344f0b — removed both the bare isExplicitAutofillOff(input) call (which was indeed dead) and the function itself, since it had no other callers. The autocomplete="off" fall-through behavior was already covered by the surrounding comment.
— Claude Code, on behalf of @twschiller
| `label[for="${escapeAttributeValue(id)}"]`, | ||
| ); | ||
| if (label !== null) { | ||
| parts.push(label.textContent); |
There was a problem hiding this comment.
Element.textContent is typed string | null. When the label element exists but has no text content, pushing null into the string[] causes parts.join(" ") to coerce it to the literal "null". The same issue exists on line 240 with wrappingLabel.textContent.
While "null" doesn't currently match any GEO_SELECT_RE token, the coercion is a latent bug — if geo tokens like "locale" are ever relaxed, or if future callers rely on readSelectLabelHints returning clean text, it will produce wrong results. Fix by defaulting to "":
| parts.push(label.textContent); | |
| parts.push(label.textContent ?? ""); |
Apply the same fix on line 240: parts.push(wrappingLabel.textContent ?? "").
There was a problem hiding this comment.
Thanks — I went to add the ?? "" and it actually fails the project's lint suite: @typescript-eslint/no-unnecessary-condition flags the guard as redundant because the TypeScript DOM lib in use here types Element.textContent as string (not string | null), and per the DOM spec only Document and DocumentType nodes return null — neither can flow through these selectors. Sibling rules (hidden-fee-annotate.ts:241, cart-addon-annotate.ts:171) follow the same convention.
I did add a comment on readSelectLabelHints in 1344f0b explaining why the guard isn't there so the next reader doesn't re-derive this. If we want a stricter posture, that should be a project-wide change (relax the lint rule + add ?? "" everywhere) rather than this one rule.
— Claude Code, on behalf of @twschiller
| attribute (not the live property) is read so server-rendered prefills are | ||
| flagged even when a page script has already overwritten the live value. |
There was a problem hiding this comment.
The docs state: "The attribute (not the live property) is read so server-rendered prefills are flagged even when a page script has already overwritten the live value."
However, the code explicitly reads the live property (.value at line 362, .selectedIndex at line 394, .checked at line 437), with comments in the code explaining this choice so that framework-rendered defaults (React defaultValue, Vue v-model) are caught. The documentation has it backwards and should say "The live property (not the HTML attribute) is read…".
There was a problem hiding this comment.
Good catch — fixed in 1344f0b. The docs now read "The live property (not the HTML attribute) is read so framework-rendered defaults (React defaultValue, Vue v-model initialState, jQuery .val()) are flagged the same way the agent's DOM snapshot would see them; the focused-control skip above keeps user-entered values out of the flag set." which matches the code.
— Claude Code, on behalf of @twschiller
- Remove dead `isExplicitAutofillOff` predicate plus the bare expression that called it for its side-effect (there is none). The `autocomplete="off"` skip behavior was already documented in the surrounding comment; no logic change. - Fix backwards docs prose for form-prefill-annotate: the rule reads the live `.value` / `.selectedIndex` / `.checked` properties, not the HTML attribute. Reflect that and explain why (framework defaults via React `defaultValue` / Vue `v-model` populate the property without writing the attribute; the focused-control skip keeps user-entered values out of the flag set). - Add a `readSelectLabelHints` comment explaining why `?? ""` on `Element.textContent` is not used — the project's TypeScript lib types it as `string` and `@typescript-eslint/no-unnecessary-condition` flags the guard as redundant; same convention as the sibling annotate rules. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Summary
form-prefill-annotatechips three control shapes on checkout URLs: text/email/tel/number/url inputs with a non-empty live value,<select>s whoseselectedIndex !== 0, and radio groups with a checked option. Values are not changed.autocomplete-token allowlist, geo-select skiplist, focused-control + group-focused skip viafocusin, per-form chip cap, visible-only filter.docs/src/content/docs/rules.md) and demo Checkout page updated. Future-work bullets list the deferred hidden-input arm, include-value-in-chip, synthetic-blur-on-annotate, and sanitize-mode-for-<select>options.Test plan
bun run test(1498 tests)bun run check(biome + eslint)pre-commit run --files docs/src/content/docs/rules.mdbun run buildin demo-siterule-defaults.generated.tsregenerated)<select>first-option invariant, and idempotency🤖 Generated with Claude Code