-
Notifications
You must be signed in to change notification settings - Fork 5
feat: setEmbeddedData() + clearEmbeddedData() for app surveys [ENG-2472] #77
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
itsjavi
wants to merge
3
commits into
main
Choose a base branch
from
claude/mobile-sdk-embedded-data-4dwd3r
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
1a60f85
feat: setEmbeddedData() + clearEmbeddedData() for app surveys [ENG-2472]
itsjavi 4a419f0
fix: refuse an invalid Date, and stop asserting booleans away [ENG-2472]
itsjavi 941d20b
chore: mark the store's Map readonly and document the display helper …
itsjavi File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,129 @@ | ||
| import { Logger } from "@/lib/common/logger"; | ||
| import type { TIngestedFieldsRecord } from "@/types/response"; | ||
|
|
||
| /** What a host app may hand to `setEmbeddedData`. `null` removes the key; `undefined` is a no-op. */ | ||
| export type TEmbeddedDataInput = Record< | ||
| string, | ||
| string | number | boolean | Date | null | undefined | ||
| >; | ||
|
|
||
| /** | ||
| * The in-memory Embedded Data bag (ENG-1844/2472): context a host app attaches to future responses | ||
| * without tying it to a trigger — `setEmbeddedData({ screen: "checkout" })` once, instead of | ||
| * repeating the same values on every possible `track()` call. Mirrors js-core's store so the web and | ||
| * mobile SDKs behave identically, key for key. | ||
| * | ||
| * Lifetime rules, all deliberate: | ||
| * | ||
| * - **In-memory, process scoped, never persisted.** Not `RNConfig`: that class writes to async | ||
| * storage, and persisting this bag would blur the Embedded Data ↔ contact-attribute boundary and | ||
| * create a stale-data / PII-at-rest surface. A cold app start begins empty; the host re-pushes. | ||
| * - **Snapshot at display, then frozen.** `SurveyWebView` copies the bag into the survey's | ||
| * `hiddenFieldsRecord` when the survey is shown; a later `setEmbeddedData` affects the next | ||
| * response, never the one on screen. | ||
| * - **No filtering here.** The SDK is a dumb pipe: the renderer applies the ingest contract — | ||
| * allow-list, coercion, `locked`, size caps — and logs what it refuses, and the server re-runs all | ||
| * of it on ingest. Filtering here would ship a second copy of those rules for the four mobile SDKs | ||
| * to drift from. | ||
| * - **No network.** Every method is a synchronous memory write, so calling `setEmbeddedData` on | ||
| * every screen change is free. Values ride the existing response payload. | ||
| * | ||
| * Backed by a `Map` rather than a plain object so a `__proto__` key is stored as data instead of | ||
| * vanishing into the prototype — the same hole the ingest contract closes on the renderer side. | ||
| */ | ||
| export class EmbeddedDataStore { | ||
| private static instance: EmbeddedDataStore | undefined; | ||
| private readonly data = new Map<string, string | number | boolean | Date>(); | ||
|
|
||
| static getInstance(): EmbeddedDataStore { | ||
| EmbeddedDataStore.instance ??= new EmbeddedDataStore(); | ||
| return EmbeddedDataStore.instance; | ||
| } | ||
|
|
||
| /** | ||
| * Merge — never replace — so refreshing a volatile field (`screen`) cannot wipe the stable ones | ||
| * (`plan`) set at launch. Per key: last write wins; `null` removes; `undefined` does nothing. | ||
| * | ||
| * The `undefined` no-op is a documented promise, not an accident: a host that builds the object | ||
| * from its own state passes every field unconditionally, so a key that is absent on the current | ||
| * screen arrives as `undefined` and must not clear the value a previous screen set. | ||
| */ | ||
| public setEmbeddedData(data: TEmbeddedDataInput): void { | ||
| // Guarded rather than thrown: this is a synchronous entry point outside the command queue's | ||
| // shield, and a host can legitimately hand over a value that was not there. A broken host build | ||
| // is a worse failure than a skipped write. An array is refused too (`typeof [] === "object"`): | ||
| // it would spread into junk numeric keys ({0: "a", 1: "b"}). | ||
| if (typeof data !== "object" || data === null || Array.isArray(data)) { | ||
| Logger.getInstance().error( | ||
| `setEmbeddedData: expected an object, got ${data === null ? "null" : typeof data} — nothing was set`, | ||
| ); | ||
| return; | ||
| } | ||
|
|
||
| for (const [key, value] of Object.entries(data)) { | ||
| if (value === undefined) continue; | ||
| if (value === null) { | ||
| this.data.delete(key); | ||
| continue; | ||
| } | ||
| // Refused rather than stored: `toISOString()` throws a RangeError on an invalid Date, and | ||
| // `getSnapshot` runs inside the effect that displays the survey — so one `new Date("nope")` | ||
| // from host code would cost the survey, not the field. This guard exists here and not in | ||
| // js-core because serializing the Date is this SDK's own step: js-core hands the Date object | ||
| // straight to the renderer, which coerces it. Never fatal, always logged. | ||
| if (value instanceof Date && Number.isNaN(value.getTime())) { | ||
| Logger.getInstance().error( | ||
| `setEmbeddedData: "${key}" is an invalid Date — the key was skipped`, | ||
| ); | ||
| continue; | ||
| } | ||
| this.data.set(key, value); | ||
|
itsjavi marked this conversation as resolved.
|
||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Remove one key, or everything when called with no argument (logout / hard context switch). | ||
| * | ||
| * "No argument" and "an argument that evaluated to `undefined`" are deliberately different: a host | ||
| * reading the key from its own state must not wipe the whole bag when that state is empty, so | ||
| * only a literal zero-argument call clears everything. | ||
| */ | ||
| public clearEmbeddedData(...args: [] | [key: string]): void { | ||
| if (args.length === 0) { | ||
| this.data.clear(); | ||
| return; | ||
| } | ||
|
|
||
| const [key] = args; | ||
| if (typeof key !== "string") { | ||
| Logger.getInstance().error( | ||
| "clearEmbeddedData: expected a field name — nothing was cleared (call with no argument to clear everything)", | ||
| ); | ||
| return; | ||
| } | ||
|
|
||
| this.data.delete(key); | ||
| } | ||
|
|
||
| /** | ||
| * A detached copy for the display-time snapshot: mutating the bag after a survey rendered must not | ||
| * reach that survey's response. `Object.fromEntries` defines own properties, so a `__proto__` key | ||
| * survives the conversion as data. | ||
| * | ||
| * Dates are serialized here rather than at the JSON boundary: `renderHtml` stringifies the props | ||
| * blob, and an ISO 8601 string is exactly what the renderer's ingest contract accepts for a `date` | ||
| * field. `setEmbeddedData` refuses an invalid Date, so `toISOString` here cannot throw. | ||
| * | ||
| * The return type spells booleans rather than asserting them away: the bag really does hold them, | ||
| * the renderer's contract really does accept them, and only the legacy `TResponseData` could not | ||
| * say so. | ||
| */ | ||
| public getSnapshot(): TIngestedFieldsRecord { | ||
| return Object.fromEntries( | ||
| Array.from(this.data, ([key, value]) => [ | ||
| key, | ||
| value instanceof Date ? value.toISOString() : value, | ||
| ]), | ||
| ); | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.