, so that
* hubspotform.js (which only loads later, inside the body, on pages that
* actually have a HubSpot Form component) can read the same values
* without duplicating them. hubspotform.js reads this lazily (at init
* time, not at parse time) so it never depends on this script having run
* first -- treat that as defense-in-depth, not a reason to relax this
* script's own placement in
.
*/
window.HS_CONFIG = window.HS_CONFIG || Object.freeze({
PORTAL_ID: '24131897',
GPC_FORM_GUID: '2b423b38-65e9-4811-8fdc-85e52e7d29d8',
DEFAULT_REGION: 'na1'
});
const PORTAL_ID = window.HS_CONFIG.PORTAL_ID;
const GPC_FORM_GUID = window.HS_CONFIG.GPC_FORM_GUID;
// ---------- Generic cookie helpers ----------
function getCookie(name) {
const match = document.cookie.match(
new RegExp('(^|;\\s*)' + name.replace(/[.*+?^{}()|[\]\\$]/g, '\\$&') + '=([^;]*)')
);
return match ? decodeURIComponent(match[2]) : null;
}
function setCookie(name, value, days) {
const expires = new Date(Date.now() + days * 24 * 60 * 60 * 1000).toUTCString();
document.cookie = name + '=' + encodeURIComponent(value) + '; expires=' + expires + '; path=/; SameSite=Lax; Secure;';
}
// ---------- OptanonConsent parsing ----------
//
// OptanonConsent is a URL-encoded, semi-structured query string, e.g.:
// isGpcEnabled=1&datestamp=2026-09-11T14:32:10Z&version=202401.1.0&
// groups=C0001%3A1%2CC0002%3A1%2CC0003%3A1%2CC0004%3A0&...
//
// Note: OneTrust's cookie format has changed across versions -- verify
// field names against your account's actual cookie before relying on
// this in prod.
function parseOptanonConsent() {
const raw = getCookie('OptanonConsent');
if (!raw) return null;
const params = new URLSearchParams(raw);
const groupsRaw = params.get('groups') || '';
const groups = {};
groupsRaw.split(',').forEach((pair) => {
const parts = pair.split(':');
const id = parts[0];
const status = parts[1];
if (id) groups[id] = status === '1';
});
return {
isGpcEnabled: params.get('isGpcEnabled') === '1',
datestamp: params.get('datestamp'),
groups: groups
};
}
// ---------- Status tracking cookie (keyed by email) ----------
//
// Keyed by email rather than a single flat record. On a shared device, a
// flat "last status sent" record gets overwritten by whoever used the
// browser most recently, causing one person's status to look "already
// sent" for someone else. Keying by email keeps each person's dedupe
// state separate even on a shared browser/cookie jar.
const STATUS_COOKIE_NAME = 'hs_gpc_status_records';
const STATUS_COOKIE_DAYS = 365;
function normalizeEmail(email) {
return String(email || '').trim().toLowerCase();
}
function getAllStatusRecords() {
const raw = getCookie(STATUS_COOKIE_NAME);
if (!raw) return {};
try {
return JSON.parse(raw) || {};
} catch (e) {
return {};
}
}
function getStatusRecordForEmail(email) {
const key = normalizeEmail(email);
if (!key) return null;
return getAllStatusRecords()[key] || null;
}
function setStatusRecordForEmail(email, lastSentGpcStatus, submissionStatus) {
const key = normalizeEmail(email);
if (!key) return;
const records = getAllStatusRecords();
records[key] = {
lastSentGpcStatus: lastSentGpcStatus,
lastUpdated: new Date().toISOString(),
submissionStatus: submissionStatus // "submitted" | "failed" | "pending"
};
// Cookies have a ~4KB practical limit -- prune oldest entries beyond a
// reasonable count so this doesn't grow unbounded on shared devices.
const MAX_RECORDS = 20;
const keys = Object.keys(records);
if (keys.length > MAX_RECORDS) {
const oldestFirst = keys.sort(
(a, b) => new Date(records[a].lastUpdated) - new Date(records[b].lastUpdated)
);
oldestFirst.slice(0, keys.length - MAX_RECORDS).forEach((k) => delete records[k]);
}
setCookie(STATUS_COOKIE_NAME, JSON.stringify(records), STATUS_COOKIE_DAYS);
}
// ---------- Email-identified submission path (preferred) ----------
async function maybeSubmitGpcStatusForEmail(email) {
const key = normalizeEmail(email);
if (!key) return;
const consent = parseOptanonConsent();
if (!consent) return; // OneTrust hasn't initialized yet
const currentGpcStatus = consent.isGpcEnabled === true;
const existing = getStatusRecordForEmail(key);
if (
existing &&
existing.lastSentGpcStatus === currentGpcStatus &&
existing.submissionStatus === 'submitted'
) {
return; // Already recorded this exact status for this specific person
}
setStatusRecordForEmail(key, currentGpcStatus, 'pending');
try {
await submitGpcStatusUpdate({ email: key, gpcStatus: currentGpcStatus });
setStatusRecordForEmail(key, currentGpcStatus, 'submitted');
} catch (err) {
setStatusRecordForEmail(key, currentGpcStatus, 'failed');
}
}
// ---------- hubspotutk-only background path (fallback, higher risk) ----------
//
// Browser-identified, not person-identified -- can misattribute status on
// a genuinely shared device. Kept as a fallback for visitors who never
// submit a form.
const HUBSPOTUTK_RETRY_ATTEMPTS = 5;
const HUBSPOTUTK_RETRY_DELAY_MS = 1000;
async function maybeSubmitGpcStatusBackground(retriesLeft = HUBSPOTUTK_RETRY_ATTEMPTS) {
const hubspotutk = getCookie('hubspotutk');
if (!hubspotutk) {
// hubspotutk is written by HubSpot's own tracking script, which only runs
// (and sets this cookie) after it processes the _hsq doNotTrack push from
// syncHubSpotDoNotTrack — it may not exist yet the instant consent
// resolves. Retry for a bounded window rather than giving up on one miss.
if (retriesLeft > 0) {
setTimeout(() => maybeSubmitGpcStatusBackground(retriesLeft - 1), HUBSPOTUTK_RETRY_DELAY_MS);
}
return;
}
const consent = parseOptanonConsent();
if (!consent) return;
const currentGpcStatus = consent.isGpcEnabled === true;
const dedupeKey = 'hutk:' + hubspotutk;
const existing = getStatusRecordForEmail(dedupeKey);
if (
existing &&
existing.lastSentGpcStatus === currentGpcStatus &&
existing.submissionStatus === 'submitted'
) {
return;
}
setStatusRecordForEmail(dedupeKey, currentGpcStatus, 'pending');
try {
await submitGpcStatusUpdate({ hubspotutk: hubspotutk, gpcStatus: currentGpcStatus });
setStatusRecordForEmail(dedupeKey, currentGpcStatus, 'submitted');
} catch (err) {
setStatusRecordForEmail(dedupeKey, currentGpcStatus, 'failed');
}
}
async function submitGpcStatusUpdate(opts) {
const email = opts.email;
const hubspotutk = opts.hubspotutk;
const gpcStatus = opts.gpcStatus;
const fields = [
{ name: 'consent_optout_gpc', value: String(gpcStatus) },
{ name: 'consent_last_updated_date_gpc', value: new Date().toISOString() }
];
if (email) fields.push({ name: 'email', value: email });
const response = await fetch(
'https://api.hsforms.com/submissions/v3/integration/submit/' + PORTAL_ID + '/' + GPC_FORM_GUID,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fields: fields,
context: hubspotutk ? { hutk: hubspotutk } : {}
})
}
);
if (!response.ok) throw new Error('HubSpot form submission failed: ' + response.status);
return response.json();
}
// Kept as a defensive fallback in case this account's OneTrust setup does
// dispatch it in some configurations — doesn't hurt to also listen.
function onOneTrustGroupsUpdated() {
maybeSubmitGpcStatusBackground();
}
window.addEventListener('OneTrustGroupsUpdated', onOneTrustGroupsUpdated);
if (window.OneTrust) {
// Fast path for when consent is already known (e.g. on a refresh, or a
// returning visitor) — avoids waiting on an event that may not fire again
// if consent isn't changing this load.
maybeSubmitGpcStatusBackground();
}
// ---------- HubSpot form submission hooks (email-identified, preferred) ----------
// Iframe embed (postMessage-based) -- also covers hbspt.forms.create(),
// since that renders the same underlying iframe and fires the same
// postMessage events.
window.addEventListener('message', (event) => {
if (!event.data || event.data.type !== 'hsFormCallback') return;
if (event.data.eventName !== 'onFormSubmitted') return;
const email = event.data.data && event.data.data.submissionValues
? event.data.data.submissionValues.email
: null;
if (email) {
maybeSubmitGpcStatusForEmail(email);
} else {
maybeSubmitGpcStatusBackground();
}
});
// HubSpot's non-iframe ("raw HTML") / v4 forms dispatch this CustomEvent
// on window. event.detail only carries { formId, instanceId } -- actual
// field values must be fetched via the async getFormFieldValues() call.
window.addEventListener('hs-form-event:on-submission:success', (event) => {
if (!window.HubSpotFormsV4) return;
HubSpotFormsV4.getFormFromEvent(event)
.getFormFieldValues()
.then((fieldValues) => {
// "0-1" is HubSpot's fixed objectTypeId for Contact -- forms with
// fields on multiple objects (e.g. Contact + Ticket) can have more
// than one "email" field, so target the Contact's explicitly.
const emailField = fieldValues.find((field) => field.name === '0-1/email');
const email = emailField ? emailField.value : null;
if (email) {
maybeSubmitGpcStatusForEmail(email);
} else {
maybeSubmitGpcStatusBackground();
}
});
});
})();
PreACT Assessments are Evolving to Align to the Enhanced ACT
PreACT Assessments are aligning with the Enhanced ACT, with new blueprints, composite scores, and science optionality at the contract level. The changes will launch according to the timeline below:
PreACT 9 Secure and PreACT Secure — online tests for higher-stakes uses like accountability and dual enrollment — will launch new blueprints in spring 2026.
PreACT and PreACT 8/9 — available in both paper and online formats for ACT prep and career navigation — will launch new blueprints in fall 2026 for PreACT, with the PreACT 8/9 launch date coming soon.
All PreACT Assessments — new Composite score (calculated with English, math, reading) and science optionality are implemented by fall 2026.
What’s Changing
What’s Staying the Same
Shorter passages in English and Reading Sections
Reduced Answer Choices for Math Questions (5 to 4)
Science Section Option at the Contract Level
Composite Score Calculated using English+Math+Reading.
PreACT Score Scales Aligned to the ACT
Predictive of ACT Scores
Paper and Online Formats for PreACT 8/9 and PreACT
Online Format for PreACT Secure Assessments
See Enhanced PreACT FAQs
Find answers to the most commonly asked questions about the PreACT alignment to the enhanced ACT.
*For the 2025-2026 school year, science is not optional, and Composite scores continue to include science. If science is not taken, a Composite score will not be generated.
**The science section will be optional based on district or school preference (and not at the individual student preference level).
Ready to bring the enhanced PreACT to your school or district?
Connect with an ACT expert to learn how you can bring PreACT to your school.
This action will open a new window. Do you want to proceed?
Welcome to ACT
If you are accessing this site from outside the United States, Puerto Rico, or U.S. Territories, please proceed to the non-U.S. version of our website.