fix(builder): deterministic ids for NumberCounter + form-relay marker

Two components of the det-id bug class were missed in the earlier migration:
NumberCounter's wrapper/counter ids and count-up script, and the shared
form-relay-wiring fid (used by ContactForm/FormContainer). Both used
Math.random() for exported HTML ids, breaking caching/diffing across exports.

Migrate both to scopeId(nodeId, fallbackSeed, prefix), threading nodeId
through NumberCounter.toHtml and relayFormWiring (via ContactForm.toHtml and
FormContainer.toHtml, both now passing nodeId as their 3rd arg).
This commit is contained in:
2026-07-12 14:43:31 -07:00
parent 177eda93d0
commit a036843728
8 changed files with 149 additions and 15 deletions
+38
View File
@@ -0,0 +1,38 @@
import { describe, test, expect } from 'vitest';
import { relayFormWiring } from './form-relay-wiring';
describe('relayFormWiring deterministic + unique fid (thread node id, no Math.random)', () => {
test('no recipient: no relay, no marker, no fid needed', () => {
const wiring = relayFormWiring(undefined, undefined, '/legacy', 'node-1');
expect(wiring.useRelay).toBe(false);
expect(wiring.marker).toBe('');
});
test('same node id -> identical marker + placeholder ids across two calls (deterministic)', () => {
const w1 = relayFormWiring('a@b.com', '/thx', undefined, 'node-form1');
const w2 = relayFormWiring('a@b.com', '/thx', undefined, 'node-form1');
expect(w1.marker).toBe(w2.marker);
expect(w1.actionAttr).toBe(w2.actionAttr);
});
test('marker id and placeholder id always match each other', () => {
const w = relayFormWiring('a@b.com', '/thx', undefined, 'node-form1');
const mid = w.marker.match(/id="([^"]+)"/)![1];
expect(w.actionAttr).toBe(`__WHP_FORM_ACTION__${mid}__`);
});
test('different node ids -> different fids (no collision)', () => {
const w1 = relayFormWiring('a@b.com', '/thx', undefined, 'node-form1');
const w2 = relayFormWiring('a@b.com', '/thx', undefined, 'node-form2');
const mid1 = w1.marker.match(/id="([^"]+)"/)![1];
const mid2 = w2.marker.match(/id="([^"]+)"/)![1];
expect(mid1).not.toBe(mid2);
});
test('no nodeId (legacy call): still deterministic across repeated calls, not random', () => {
const w1 = relayFormWiring('a@b.com', '/thx', undefined);
const w2 = relayFormWiring('a@b.com', '/thx', undefined);
expect(w1.marker).toBe(w2.marker);
expect(w1.actionAttr).toBe(w2.actionAttr);
});
});
+7 -2
View File
@@ -9,7 +9,7 @@
* HTML — see PR #47 review).
*/
import { escapeAttr, safeUrl } from './escape';
import { escapeAttr, safeUrl, scopeId } from './escape';
export interface RelayWiring {
/** true when a recipient is set (relay path); false = legacy formAction fallback */
@@ -26,16 +26,21 @@ export interface RelayWiring {
* @param recipientEmail the "Send submissions to" address (empty/undefined = no relay)
* @param thankYouUrl optional post-submit redirect (blank = hosted thank-you page)
* @param fallbackAction the form's existing action to use when no recipient is set
* @param nodeId the Craft node id of the calling form component, used to scope
* the marker/placeholder id deterministically and uniquely --
* see `scopeId` in ./escape. Falls back to a stable hash of the
* recipient/thankYouUrl/fallbackAction when omitted (never random).
*/
export function relayFormWiring(
recipientEmail: string | undefined,
thankYouUrl: string | undefined,
fallbackAction: string | undefined,
nodeId?: string,
): RelayWiring {
if (!recipientEmail) {
return { useRelay: false, marker: '', actionAttr: escapeAttr(safeUrl(fallbackAction || '#')), honeypot: '' };
}
const fid = 'F' + Math.random().toString(36).slice(2, 8);
const fid = scopeId(nodeId, `${recipientEmail}::${thankYouUrl || ''}::${fallbackAction || ''}`, 'F');
return {
useRelay: true,
marker: `<!--WHP-FORM id="${fid}" recipient="${escapeAttr(recipientEmail)}" thankyou="${escapeAttr(thankYouUrl || '')}"-->`,