Flow
Implementation and verification gates
This checklist gives practitioners an applied path from implementation decisions through release evidence.
1State contract
Use an expiring opaque intent with explicit phases and cleanup.
2Host contract
Identify the native action through a documented semantic boundary.
3Bounded discovery
Scope observers, prevent duplicate wrappers, and cancel waits.
4Honest readiness
Recheck native enablement and never fake host authority.
5Input boundary
Scope the dialog, use the supported setter, and avoid client secrets.
6Failure cleanup
Restore native UI and clear resources on timeout, route, or error.
7Browser tests
Exercise delayed insertion, rerender, ambiguity, double click, and logout.
8Release checks
Parse, lint, syntax-check, scan, and document any missing behavior tests.
Practical Implementation and Verification Plan
The safest way to build this kind of browser adapter is to write the contract before writing the wrapper. The contract should say what the extension may observe, what it may render, what it may ask the host to do, and what it must never own. The source change provides a useful starting point, but the following plan turns that starting point into an implementable checklist.
1. Define the state and its lifetime. Use an explicit record with a role identifier, checkout identifier, phase, and expiry, with phases Idle, Selected, WaitingForAction, WaitingForInput, Applied, Canceled, and Failed. Keep it in a module-owned object rather than an unbounded global. The role identifier should be opaque. Do not place a password, long-lived token, or operator identity in the record. Clear it after one attempt and when the route, user session, or checkout identity changes.
2. Establish a host contract. Prefer a documented data attribute or integration hook that identifies the payment action and its owning checkout. Record the element identity when it is discovered. A wrapper can copy presentation, but it must not duplicate business logic. Keep the native control reachable, and make the wrapper's click handler call a single forwarding function. If the host exposes a supported command or API, use that instead of cloning DOM nodes.
3. Bound discovery. Create one observer for the smallest useful subtree. On each mutation, check whether the current element is still connected and whether its checkout identity matches the state record. Use a processed marker or a weak reference to avoid duplicate wrappers. Disconnect the observer when the action has been forwarded, when the user cancels, or when a timeout fires. A polling loop is acceptable as a fallback, but it needs a maximum duration, a maximum attempt count, and a cancellation handle.
4. Handle readiness honestly. A disabled button is a host decision. Do not force it enabled in the native element. If a visual clone is enabled for accessibility or layout reasons, its handler must still recheck the native control immediately before forwarding. Remove copied loading decoration only when the wrapper makes clear that the host is still deciding. If the native control stays disabled, show a neutral error and restore it rather than waiting forever.
5. Treat input as an ownership boundary. Scope the credential field to the dialog created by the forwarded action. Use the framework's native value setter when the field is controlled, then dispatch the exact input and change events the host expects. Never use a broad document query that might select a search field or another dialog. Do not embed credentials in the extension bundle. Request a short-lived capability from a trusted service, or have the host perform the role switch itself. Redact role and credential context from console output.
6. Centralize cleanup. The cleanup function should clear timers, disconnect observers, remove the wrapper, restore the native element, clear the selected role, and release references. Call it from success, timeout, cancel, logout, navigation, and exception paths. A cleanup reason such as success, host-replaced, or expired is enough for diagnostics; it should not include private values.
7. Test the state machine. A minimum browser harness should cover delayed insertion, disabled-to-enabled readiness, native rerender, missing input, competing input, double click, route change, logout, and expiration. Assert that exactly one native click occurs, the role is cleared, the observer disconnects, and no timer remains. Add a negative test proving that a stale wrapper cannot submit a later checkout. Add a secret-scanning test that fails if credential-like literals appear in source or built assets.
8. Verify the package. Parse the manifest, check script order, syntax-check every JavaScript file, and run lint and tests in a clean checkout. A package test command that only echoes a message is not evidence of behavior. Record the browser and host versions used for the harness, and capture failures as structured events without private payloads.
The source sequence also suggests a useful review habit: treat corrective commits as evidence of a boundary that was initially underspecified. Waiting for a disabled native action and changing how the input value is applied are not cosmetic tweaks; they are signals that readiness and controlled-input semantics deserve explicit contracts. Once those contracts exist, the extension can remain a small convenience layer instead of turning into an unbounded second checkout implementation.
Corrected adapter contract
The adapter must bind every handler to an element resolved from the current checkout surface, keep discovery and readiness bounded, and make cancellation tear down the wait. It must never ship a reusable credential. The trusted host or authorization service should issue a short-lived capability scoped to the current checkout and operation, then validate it again at the server boundary.
function bindAdapter(root: HTMLElement, onSelect: (event: Event) => void) {
const drawer = root.querySelector("[data-role-drawer]");
if (!(drawer instanceof HTMLElement)) throw new Error("role-drawer-missing");
const controller = new AbortController();
drawer.addEventListener("click", onSelect, { signal: controller.signal });
return () => controller.abort();
}
async function waitUntilReady(
action: HTMLButtonElement,
signal: AbortSignal,
timeoutMs = 5_000,
) {
const deadline = performance.now() + timeoutMs;
while (action.disabled) {
if (signal.aborted) throw new DOMException("aborted", "AbortError");
const remaining = deadline - performance.now();
if (remaining <= 0) throw new Error("readiness-timeout");
await new Promise<void>((resolve, reject) => {
let timer: ReturnType<typeof setTimeout>;
const onAbort = () => {
clearTimeout(timer);
reject(new DOMException("aborted", "AbortError"));
};
const finish = () => {
signal.removeEventListener("abort", onAbort);
resolve();
};
timer = setTimeout(finish, Math.min(100, remaining));
signal.addEventListener("abort", onAbort, { once: true });
});
}
if (!action.isConnected) throw new Error("checkout-action-detached");
}
async function forwardCheckout(root: HTMLElement, checkoutId: string, signal: AbortSignal) {
const action = root.querySelector("[data-native-checkout-action]");
if (!(action instanceof HTMLButtonElement)) throw new Error("checkout-action-missing");
await waitUntilReady(action, signal);
const capability = await requestScopedCapability({ checkoutId, signal });
return invokeHostAction(action, { capability });
}
The example binds the drawer instead of relying on an undeclared element, rechecks the live native action, stops on abort or deadline, and carries only an expiring capability into the host-owned action.