Skip to content

Feat/popups v2 rules - #84

Open
anyelopetit wants to merge 35 commits into
mainfrom
feat/popups-v2-rules
Open

anyelopetit wants to merge 35 commits into
mainfrom
feat/popups-v2-rules

Conversation

@anyelopetit

@anyelopetit anyelopetit commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

Implements the browser-runtime half of Popup Rules V2 for the paired Rails change in hellotext#5833.

The SDK evaluates page, session, and activity conditions in the browser, follows SPA navigation, declares the rules contract to Rails, and coordinates late identification with the new Rails receipt protocol before re-evaluating popup eligibility. Profile, audience, and signal conditions stay on the server and are never evaluated from browser-visible contact data.

Important

Versioning is TENTATIVE. package.json declares 2.6.1 and the Rails branch pins @hellotext/hellotext 2.6.0. Both numbers were assumptions. Ahmed should set the final version here, and the matching Rails pin, before merge.

Runtime contract with Rails

  1. Rails validates and stores the merchant rule document.
  2. On each popup request, the SDK sends X-Hellotext-Popup-Rules: 1 (POPUP_RULES_CONTRACT in src/api/popups.js).
  3. Rails evaluates profile, audience, signal, and country conditions and drops the alternatives that cannot match.
  4. Rails sends only the page, session, and activity conditions of the surviving alternatives. An alternative with no browser conditions matches on any page.
  5. The SDK evaluates them against the current visit. A popup is displayed only if both the server and the browser decisions pass.

{ eligible: false } responses, with or without a reason, are a deliberate outcome and render nothing.

Rails withholds a popup whose every alternative needs browser conditions from a runtime that does not send the header (reason: 'runtime_unsupported'). An older SDK installed on a store therefore never shows a page-targeted popup on every page. It gets that popup once the store updates. The Rails editor warns the merchant while an outdated runtime has asked for a popup during the last 7 days, even if up-to-date browsers are also seen.

The client evaluator expects the canonical { lanes: [...] } document:

  • alternatives are ORed;
  • conditions within one alternative are ANDed;
  • multiple positive text values are alternatives;
  • excluded text values are required negatives;
  • invalid input, unknown fields, and unknown operators fail closed.

The composer limits a popup to three alternatives, but the runtime stays defensive when evaluating persisted data.

Supported client-side context

Rule Runtime source Important behavior
Page URL Canonical current route Stored as page.path. Matches the storefront path (see normalization below).
Page title document.title Title changes are observed when title rules exist, including SPA updates.
Referrer document.referrer May be empty for direct traffic, strict referrer policies, and privacy tools.
Scroll depth Current document scroll position Evaluated for the current page.
Time on page Time since canonical route entry Resets only on a canonical route change, not on a title-only update.
Pages viewed Visit route count Increments only when the canonical route changes.
Browser language navigator.languages[0], falling back to navigator.language English, Spanish, Portuguese, French, or Dutch. Not the contact profile language.
Visitor type and browser Runtime visit context New or returning; Chrome, Safari, Firefox, or Edge.
UTM source, medium, campaign Current tab visit campaign See UTM semantics below.
Product viewed, cart added, purchase completed, form completed Activity emitted for the current business in this tab See activity semantics below.

Device is not a rule: it stays a popup design setting and is fixed when the popup controller connects.

Navigation and route normalization

The popup runtime watches:

  • History API pushState and replaceState;
  • browser popstate and hashchange;
  • Turbo load and render events;
  • relevant document.title mutations.

Routes are canonicalized with the same rules as the Rails normalizer and editor preview, so a saved Page URL value always compares against what the SDK reads:

  • the path is compared case-insensitively;
  • query strings and ordinary anchors are ignored and do not create a page view;
  • hash routes (#/…, #!/…) are kept as the route;
  • for is / is not, /sale, /sale/, and /sale/index.html are the same page;
  • for contains / does not contain, a trailing slash typed by the merchant is kept;
  • encoded separators stay distinct (/a%2Fb is not /a/b);
  • only a canonical route change resets time on page and increments pages viewed.

Merchants may enter a path or an http(s):// URL. An exact path gains its leading slash (sale/sale); a contained fragment is kept as typed (rojo matches /zapato-rojo). Rails refuses bare hosts (shop.example/sale), empty or /-only fragments, and non-web schemes.

UTM semantics

  • UTM rules read the campaign of the current tab visit, never the hello_utm attribution cookie.
  • If the current URL carries utm_source, utm_medium, or utm_campaign, those values are used alone and are remembered in sessionStorage for the rest of the tab visit. Otherwise the remembered campaign is used. The two are never mixed.
  • Hash-route parameters (#/route?utm_…) are read only when the address itself carries none, so /?utm_source=a#/sale?utm_source=b reads a.
  • Values are parsed with URLSearchParams, compared case-insensitively, and + reads as a space.

Activity semantics

Activity is scoped to the current business and the current browser tab. Once an event occurs, it counts on every later page in that tab, and a new tab starts without it. Events from another business or from before the tab visit do not unlock a rule.

The commerce activities are product decisions, but each integration must emit the event at a successful storefront action. Shopify, Wix, and VTEX must be smoke-tested:

  • Product viewed requires the product-view event.
  • Added to cart requires a confirmed cart add event. A webchat button click alone does not count.
  • Purchase completed requires a completed purchase event.
  • Form completed is emitted by Hellotext for the business that originated the form.

Late identification receipt protocol

Popup eligibility can change after identify(), because a visitor may meet a profile, audience, or signal condition only once Rails has mapped the identification.

  1. identify() submits the identification and receives a short-lived receipt.
  2. The SDK polls GET /v1/public/identifications/:id with the session header, using bounded backoff.
  3. While the receipt is pending, popup loading is held so an anonymous decision is not shown right before a known-contact decision.
  4. On completion, the SDK remembers the contact and reloads popup eligibility.
  5. On terminal failure, it resumes anonymous evaluation instead of waiting forever.

Safety properties:

  • Rails binds the receipt to the business and session.
  • Stale responses from a previous identify call, a changed business, or a changed session are ignored.
  • Pending, transient server failure, and rate-limit responses retry; malformed or terminal responses stop cleanly.
  • The same-visit initialization path keeps a valid pending identification instead of cancelling it.
  • Servers that do not return a receipt keep the legacy immediate path.

Backward compatibility of that legacy path covers identification only. Page, session, and activity rules depend on the Rails capability gate described above.

Failure behavior and privacy boundaries

  • Local rule parsing fails closed: an invalid condition never makes a popup eligible.
  • The SDK never receives profile, audience, or signal values.
  • Browser language is not treated as a durable profile fact.
  • URL suggestions are only an authoring aid and do not affect runtime evaluation.
  • A server-side no-op identification is not treated as a completed identity change.
  • Receipt responses are no-store, and the SDK never uses a receipt from another session.

Known issue (not introduced here)

Rails renders data-hellotext--popup-delay-value, but popup_controller.js does not declare or read a delay value, so trigger delay has no effect.

Build and dependency updates

  • Version declared as 2.6.1 (tentative, see above).
  • Regenerated lib/ and dist/ artifacts are included.
  • DOMPurify was rebuilt at 3.4.15 using the locked dependency graph.

Validation

  • npm test45 suites, 975 tests passing, including the X-Hellotext-Popup-Rules header assertion.
  • npm run build → completed successfully.
  • npx prettier --check on the files changed by the latest commits → passing. The repository has no format script, and this PR does not reformat untouched files.
  • GitHub Actions run on the latest push (Node 20, Node 22, security scan).

Release checklist

  1. Before merge: Ahmed sets the final version here and the matching Rails pin in hellotext#5833.
  2. Deploy hellotext#5833 so the receipt endpoint and capability gate are live.
  3. Publish this SDK and update the Rails pin to that exact version.
  4. Test an installed popup on real Shopify, Wix, and VTEX storefronts:
    • SPA route changes and URL normalization;
    • UTM in standard and hash routes;
    • cart and purchase actions;
    • anonymous country fallback and a late identify();
    • a store still on an older SDK.
  5. Please squash merge.

Intentionally out of scope

  • Display frequency.
  • Browser-side profile, audience, or signal evaluation.
  • Hellotext link, Device, Gender, Age/Birthday, City/Region rules.
  • Cart abandoned, order shipped, and refund activities.
  • Page-history rules such as “visited a previous page in this visit”.
  • Popularity-ranked URL suggestions.
  • Inferring commerce activity from intent-only UI events.

@anyelopetit anyelopetit self-assigned this Sep 14, 2026
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 14, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-17T15:05:54.742699Z 6c0848e New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ce1f90104a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/controllers/popup_controller.js Outdated
Comment thread src/hellotext.js Outdated
Comment thread src/hellotext.js Outdated
Comment thread src/hellotext.js Outdated
Comment thread src/hellotext.js
Comment thread src/hellotext.js Outdated
Comment thread src/hellotext.js

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bd3d873ba5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/hellotext.js Outdated
Comment thread src/hellotext.js Outdated
Comment thread src/hellotext.js Outdated
Comment thread src/controllers/popup_controller.js
Comment thread src/controllers/popup_controller.js
Comment thread src/controllers/popup_controller.js Outdated
Comment thread README.md Outdated
Comment thread src/controllers/popup_controller.js Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: de17524ce2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/hellotext.js Outdated
Comment thread src/controllers/popup_controller.js
Comment thread src/controllers/popup_controller.js
Comment thread src/hellotext.js Outdated
Comment thread src/controllers/popup_controller.js
Comment thread src/controllers/popup_controller.js
Adds the browser half of the split evaluation. The server decides profile conditions and
sends only the page conditions of the lanes that survived, so this treats the payload as
the whole remaining question: lanes are OR'd, conditions inside a lane are AND'd, and no
lanes means the popup may display.

PopupDisplayRules mirrors Popup::DisplayRules::PageEvaluator, including complement
semantics for unset values, so Test a URL and the runtime cannot disagree.

Scroll depth and time on page only grow, so a popup gated on them is re-checked on scroll
and once a second instead of being decided on connect. Watching starts only when a rule
needs a measurement, and stops the moment the popup displays: a popup counts as shown
when it actually appears, never when its rules merely match.

An eligible: false response carries no markup and is treated as nothing to render rather
than a failure.
@anyelopetit
anyelopetit force-pushed the feat/popups-v2-rules branch 2 times, most recently from de17524 to beec397 Compare September 16, 2026 14:52

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: beec397f97

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/controllers/popup_controller.js Outdated
Comment thread src/hellotext.js Outdated
Comment thread src/controllers/popup_controller.js Outdated
Comment thread src/controllers/popup_controller.js Outdated
Comment thread src/models/popup_display_rules.js

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a0d07955a4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/controllers/message_controller.js Outdated
Comment thread src/controllers/popup_controller.js
Comment thread src/hellotext.js
Comment thread src/controllers/popup_controller.js Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 38acfaea16

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/hellotext.js Outdated
Comment thread src/controllers/popup_controller.js Outdated
Comment thread src/controllers/popup_controller.js Outdated
Comment thread src/controllers/popup_controller.js
Comment thread __tests__/controllers/message_controller_test.js Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f048e59a7a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/hellotext.js Outdated
Comment thread src/hellotext.js Outdated
Comment thread src/hellotext.js Outdated
Comment thread src/hellotext.js Outdated
Comment thread src/controllers/popup_controller.js Outdated
Comment thread src/controllers/popup_controller.js
Comment thread src/models/popup_display_rules.js
Comment thread src/models/form.js

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 70df204cba

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/controllers/popup_controller.js Outdated
Comment thread dist/hellotext.js.LICENSE.txt Outdated
Comment thread src/controllers/popup_controller.js

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d15985a71c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/hellotext.js
)
return

if (response.data.status === 202) continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Clear identification state when polling is exhausted

If the status endpoint remains at 202 through the eighth attempt—such as when the identification job takes longer than roughly 16 seconds—this continue exits the loop without clearing identificationPending. Because identify() already unmounted the active popup and loadPopup() refuses to run while that flag is set, popup evaluation then remains disabled for the rest of the visit. Clear the pending state and restore anonymous popup evaluation when the retry budget is exhausted.

Useful? React with 👍 / 👎.

Comment thread src/api/popups.js
// `eligible: false` with no markup when this visitor does not qualify. That is a
// deliberate outcome rather than an error, so it is treated the same as "nothing to
// render" instead of surfacing as a failure.
if (data.eligible === false || !data.html) return null

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep SPA page tracking active without eligible markup

When the initial response is eligible: false, this early return means no popup controller is mounted, but PopupController.watchNavigation() is the only runtime component that records SPA route changes. For example, an anonymous visitor can navigate through several routes and then identify into an eligible audience; the reloaded popup receives a stale page-view count and a session.page_views rule may stay false. The remaining uncovered case is specifically this no-markup response, so route tracking needs to live outside the rendered popup controller or remain active while eligibility is false.

Useful? React with 👍 / 👎.

Comment thread src/hellotext.js
)
return

User.remember(details.externalId, details.source, details.fingerprint)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Cancel pending identification when forgetting the user

When an app awaits identify() and then logs the user out before receipt polling completes, identify() has already returned but forget() does not invalidate the background identification. A later successful poll reaches this line and recreates the user cookies that logout just cleared, so subsequent activity can be attributed to the previous user. forget() must advance the identification version, cancel polling, and clear the pending state before deleting the identity.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6c0848e9a9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/hellotext.js
Comment on lines +502 to +506
const identificationVersion = ++this.identificationVersion
const businessId = this.visitBusinessId
const session = this.session
this.identificationPending = true
this.cancelIdentificationPolling()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Invalidate pending identification before the fingerprint shortcut

When user A's receipt is still polling and the app switches back to the previously remembered user B, identify(B) returns from the fingerprint-match branch before reaching this version increment. The request for A therefore remains current and can later overwrite B's identity cookies, despite B being the newest identification request. Advance the identification version and cancel pending polling before applying the already-identified shortcut.

Useful? React with 👍 / 👎.

watchNavigation() {
if (this.onNavigation) return

this.lastRoute = this.pageRoute()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reconcile route state when reconnecting the controller

If an undisplayed popup element is detached, the SPA changes routes, and the same element is reinserted, disconnect() removes the only navigation listeners and this assignment accepts the new route without recording it. The controller also retains its old connectedAt, so the new page is omitted from session.page_views and inherits the previous page's elapsed time, potentially making page-count or time-on-page rules evaluate incorrectly. Compare the reconnect route with the previously observed route and update both counters before evaluating.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant