@wireai/activation 0.14.3 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +73 -0
- package/README.md +11 -7
- package/dist/analytics/index.d.mts +5 -5
- package/dist/analytics/index.d.ts +5 -5
- package/dist/analytics/index.js.map +1 -1
- package/dist/analytics/index.mjs.map +1 -1
- package/dist/coachmarks/index.d.mts +1 -1
- package/dist/coachmarks/index.d.ts +1 -1
- package/dist/coachmarks/index.js +47 -2
- package/dist/coachmarks/index.js.map +1 -1
- package/dist/coachmarks/index.mjs +47 -2
- package/dist/coachmarks/index.mjs.map +1 -1
- package/dist/{currentSession-CW_5Mq4O.d.ts → currentSession-CFSRZ2wg.d.ts} +10 -4
- package/dist/{currentSession-CUvTOchb.d.mts → currentSession-orZy5p1e.d.mts} +10 -4
- package/dist/{decision-Bgo17oH7.d.mts → decision-3vWLuBlO.d.ts} +11 -2
- package/dist/{decision-Bkh_LigV.d.ts → decision-yBj2AyPW.d.mts} +11 -2
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js.map +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/questionnaire/index.d.mts +3 -3
- package/dist/questionnaire/index.d.ts +3 -3
- package/dist/questionnaire/index.js +48 -3
- package/dist/questionnaire/index.js.map +1 -1
- package/dist/questionnaire/index.mjs +48 -3
- package/dist/questionnaire/index.mjs.map +1 -1
- package/dist/reviews/index.d.mts +10 -6
- package/dist/reviews/index.d.ts +10 -6
- package/dist/reviews/index.js +167 -18
- package/dist/reviews/index.js.map +1 -1
- package/dist/reviews/index.mjs +167 -18
- package/dist/reviews/index.mjs.map +1 -1
- package/dist/showcase/index.d.mts +1 -1
- package/dist/showcase/index.d.ts +1 -1
- package/dist/showcase/index.js +47 -2
- package/dist/showcase/index.js.map +1 -1
- package/dist/showcase/index.mjs +47 -2
- package/dist/showcase/index.mjs.map +1 -1
- package/dist/{transport-j5gFfJhK.d.mts → transport-s5QxA-ci.d.mts} +18 -13
- package/dist/{transport-B_0SgCBe.d.ts → transport-xqqSFqxs.d.ts} +18 -13
- package/dist/{types-Cju-1_jT.d.mts → types-Byx306Kv.d.mts} +25 -10
- package/dist/{types-BcmagF6K.d.mts → types-D_0B0yay.d.mts} +7 -2
- package/dist/{types-BcmagF6K.d.ts → types-D_0B0yay.d.ts} +7 -2
- package/dist/{types-h2BZvl1t.d.ts → types-tdATL5z0.d.ts} +25 -10
- package/package.json +1 -1
- package/src/WireOnboarding.tsx +4 -1
- package/src/coachmarks/runtime.ts +103 -2
- package/src/coachmarks/types.ts +7 -2
- package/src/context/deviceId.ts +32 -0
- package/src/reviews/ReviewGate.tsx +86 -37
- package/src/reviews/decision.ts +11 -1
- package/src/reviews/idempotency.ts +71 -0
- package/src/reviews/index.ts +4 -2
- package/src/reviews/runtime.ts +5 -3
- package/src/reviews/transport.ts +17 -12
- package/src/reviews/types.ts +7 -0
- package/src/types.ts +10 -4
- package/src/utils/submitResult.ts +17 -9
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { k as ReviewTarget, S as SubmitResult, g as ReviewDecisionResponse, j as ReviewSubmission } from './types-
|
|
1
|
+
import { k as ReviewTarget, S as SubmitResult, g as ReviewDecisionResponse, j as ReviewSubmission } from './types-Byx306Kv.mjs';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* What became of a review POST — the SHARED three-outcome verdict, re-exported under the name the
|
|
@@ -25,23 +25,28 @@ type ReviewSubmitResult = SubmitResult;
|
|
|
25
25
|
*
|
|
26
26
|
* It was, for exactly one unpublished release, and the boolean was the bug. `false` meant both
|
|
27
27
|
* "nothing reached the server" and "the server answered non-2xx", and the one caller that reads
|
|
28
|
-
* this (`ReviewGate.postOnce`) treats `false` as "still owed" and re-posts.
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
28
|
+
* this (`ReviewGate.postOnce`) treats `false` as "still owed" and re-posts. At the time there was
|
|
29
|
+
* no idempotency key on the wire at all, so a 502 returned AFTER the insert commits meant the
|
|
30
|
+
* re-post wrote a SECOND row, double-counting `count` and corrupting `avg` — the precise corruption
|
|
31
|
+
* the one-row latch exists to prevent. A response of any status proves the server was reached, and
|
|
32
|
+
* that is a different question from whether it liked the row. So the two are different values, and
|
|
33
|
+
* they stay different: the split is what lets the caller decide, and the retry-on-`rejected`
|
|
34
|
+
* question is a recovery-budget decision the key does not answer on its own.
|
|
34
35
|
*
|
|
35
36
|
* ── THE RESIDUAL, STATED HONESTLY ────────────────────────────────────────────────────────────
|
|
36
37
|
*
|
|
37
38
|
* `unsent` is not proof the server never got the row. A connection dropped after the request was
|
|
38
39
|
* written — or after the row committed — surfaces as a thrown/rejected `fetch` here, exactly like
|
|
39
|
-
* an offline device.
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
40
|
+
* an offline device. That used to make the `unsent` re-post SAFER rather than SAFE, and it is what
|
|
41
|
+
* the client-minted idempotency key now covers: `ReviewSubmission.idempotency_key` reaches
|
|
42
|
+
* `CreateReviewRequest`, and the server upserts the derived row rather than inserting a second one.
|
|
43
|
+
*
|
|
44
|
+
* IT IS A CAPABILITY, NOT A GUARANTEE, and the condition is not ours to enforce from in here. The
|
|
45
|
+
* server honours the key only when the post also carries a unit it can scope it to —
|
|
46
|
+
* `meta.device_key`, else `session_id` — and ignores it outright otherwise. `ReviewGate` fills the
|
|
47
|
+
* device key in for exactly this reason, but this function posts whatever body it is handed, so a
|
|
48
|
+
* caller that assembles its own body without a unit is back in the old world where a lost detractor
|
|
49
|
+
* is a gap in the data and a duplicated one is a lie in the data.
|
|
45
50
|
*/
|
|
46
51
|
declare const submitReview: (target: ReviewTarget | undefined, review: ReviewSubmission) => Promise<ReviewSubmitResult>;
|
|
47
52
|
/**
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { k as ReviewTarget, S as SubmitResult, g as ReviewDecisionResponse, j as ReviewSubmission } from './types-
|
|
1
|
+
import { k as ReviewTarget, S as SubmitResult, g as ReviewDecisionResponse, j as ReviewSubmission } from './types-tdATL5z0.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* What became of a review POST — the SHARED three-outcome verdict, re-exported under the name the
|
|
@@ -25,23 +25,28 @@ type ReviewSubmitResult = SubmitResult;
|
|
|
25
25
|
*
|
|
26
26
|
* It was, for exactly one unpublished release, and the boolean was the bug. `false` meant both
|
|
27
27
|
* "nothing reached the server" and "the server answered non-2xx", and the one caller that reads
|
|
28
|
-
* this (`ReviewGate.postOnce`) treats `false` as "still owed" and re-posts.
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
28
|
+
* this (`ReviewGate.postOnce`) treats `false` as "still owed" and re-posts. At the time there was
|
|
29
|
+
* no idempotency key on the wire at all, so a 502 returned AFTER the insert commits meant the
|
|
30
|
+
* re-post wrote a SECOND row, double-counting `count` and corrupting `avg` — the precise corruption
|
|
31
|
+
* the one-row latch exists to prevent. A response of any status proves the server was reached, and
|
|
32
|
+
* that is a different question from whether it liked the row. So the two are different values, and
|
|
33
|
+
* they stay different: the split is what lets the caller decide, and the retry-on-`rejected`
|
|
34
|
+
* question is a recovery-budget decision the key does not answer on its own.
|
|
34
35
|
*
|
|
35
36
|
* ── THE RESIDUAL, STATED HONESTLY ────────────────────────────────────────────────────────────
|
|
36
37
|
*
|
|
37
38
|
* `unsent` is not proof the server never got the row. A connection dropped after the request was
|
|
38
39
|
* written — or after the row committed — surfaces as a thrown/rejected `fetch` here, exactly like
|
|
39
|
-
* an offline device.
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
40
|
+
* an offline device. That used to make the `unsent` re-post SAFER rather than SAFE, and it is what
|
|
41
|
+
* the client-minted idempotency key now covers: `ReviewSubmission.idempotency_key` reaches
|
|
42
|
+
* `CreateReviewRequest`, and the server upserts the derived row rather than inserting a second one.
|
|
43
|
+
*
|
|
44
|
+
* IT IS A CAPABILITY, NOT A GUARANTEE, and the condition is not ours to enforce from in here. The
|
|
45
|
+
* server honours the key only when the post also carries a unit it can scope it to —
|
|
46
|
+
* `meta.device_key`, else `session_id` — and ignores it outright otherwise. `ReviewGate` fills the
|
|
47
|
+
* device key in for exactly this reason, but this function posts whatever body it is handed, so a
|
|
48
|
+
* caller that assembles its own body without a unit is back in the old world where a lost detractor
|
|
49
|
+
* is a gap in the data and a duplicated one is a lie in the data.
|
|
45
50
|
*/
|
|
46
51
|
declare const submitReview: (target: ReviewTarget | undefined, review: ReviewSubmission) => Promise<ReviewSubmitResult>;
|
|
47
52
|
/**
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { O as OnboardingTheme } from './types-BKfpdZzX.mjs';
|
|
2
|
-
import { C as CoachmarkStorage } from './types-
|
|
2
|
+
import { C as CoachmarkStorage } from './types-D_0B0yay.mjs';
|
|
3
3
|
import { a as WireFeatures, b as WireFeaturesConfig } from './types-BpwiRpA8.mjs';
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -25,20 +25,28 @@ import { a as WireFeatures, b as WireFeaturesConfig } from './types-BpwiRpA8.mjs
|
|
|
25
25
|
*
|
|
26
26
|
* `submitReview` was a boolean for exactly one unpublished release, and the boolean WAS the bug.
|
|
27
27
|
* `false` meant both "nothing reached the server" and "the server answered non-2xx", and the caller
|
|
28
|
-
* treats `false` as "still owed" and re-posts.
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
28
|
+
* treats `false` as "still owed" and re-posts. At the time neither body carried a client id, so
|
|
29
|
+
* there was NO idempotency key on the wire on either surface: a 502 returned AFTER the insert
|
|
30
|
+
* commits meant the re-post wrote a SECOND row. A response of any status proves the server was
|
|
31
|
+
* reached, and that is a different question from whether it liked the payload — which is why the
|
|
32
|
+
* three values stay, on both surfaces, whatever the wire gains.
|
|
33
33
|
*
|
|
34
34
|
* ── THE RESIDUAL, STATED HONESTLY ─────────────────────────────────────────────────────────────
|
|
35
35
|
*
|
|
36
36
|
* `unsent` is not proof the server never got it. A connection dropped after the request was written
|
|
37
37
|
* — or after the row committed — surfaces as a thrown/rejected `fetch`, exactly like an offline
|
|
38
|
-
* device.
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
38
|
+
* device. Closing that window needs a client-minted idempotency key the server upserts on, and AS
|
|
39
|
+
* OF 2026-08-19 THE TWO SURFACES DIFFER — do not read this paragraph as one fact about both:
|
|
40
|
+
*
|
|
41
|
+
* • REVIEWS — closed. `CreateReviewRequest` takes an optional `idempotency_key` and the server
|
|
42
|
+
* upserts a derived row on it, so a `ReviewGate` re-post merges instead of duplicating. It is a
|
|
43
|
+
* capability, not a guarantee: the server honours the key only when the post also carries a
|
|
44
|
+
* unit to scope it to (`meta.device_key`, else `session_id`), so a caller that strips both is
|
|
45
|
+
* back to the old behaviour.
|
|
46
|
+
* • QUESTIONNAIRES — still open, and still exactly as this paragraph used to read for both. The
|
|
47
|
+
* response body carries no client id and the server has no idempotency path for it at all, so
|
|
48
|
+
* retrying only on `unsent` there is SAFER, not SAFE. Prefer losing a row over inventing one: a
|
|
49
|
+
* lost detractor is a gap in the data, a duplicated one is a lie in the data.
|
|
42
50
|
*/
|
|
43
51
|
type SubmitResult = "accepted" | "rejected" | "unsent";
|
|
44
52
|
|
|
@@ -119,6 +127,13 @@ interface ReviewSubmission {
|
|
|
119
127
|
contact?: string;
|
|
120
128
|
session_id?: string;
|
|
121
129
|
source?: string;
|
|
130
|
+
/**
|
|
131
|
+
* OPTIONAL client-minted key for the two-phase submit: two posts carrying the same key upsert ONE
|
|
132
|
+
* row instead of creating two. The server honours it ONLY when the post also carries a resolvable
|
|
133
|
+
* user — `meta.device_key`, else `session_id` — and silently ignores it otherwise, so it always
|
|
134
|
+
* travels with one of those. One key per gate impression, stable across that impression's posts.
|
|
135
|
+
*/
|
|
136
|
+
idempotency_key?: string;
|
|
122
137
|
meta?: Record<string, unknown>;
|
|
123
138
|
}
|
|
124
139
|
/**
|
|
@@ -18,8 +18,13 @@ interface TargetRect {
|
|
|
18
18
|
}
|
|
19
19
|
/**
|
|
20
20
|
* The host-injected gate storage. SYNCHRONOUS on purpose — a coachmark's
|
|
21
|
-
* "seen?" gate must resolve during render
|
|
22
|
-
*
|
|
21
|
+
* "seen?" gate must resolve during render. This is ENFORCED, fail closed: a
|
|
22
|
+
* probe on first read catches an adapter whose `getItem` returns a thenable
|
|
23
|
+
* (AsyncStorage handed straight in) and substitutes a closed adapter — every
|
|
24
|
+
* gate reads "seen", nothing shows, nothing fires, one dev warning names the
|
|
25
|
+
* fix (see `validateGateStorage` in ./runtime). A raw async adapter would
|
|
26
|
+
* otherwise report every gate unseen forever: tours replay each open, and a
|
|
27
|
+
* review gate with `minSessions` 0/1 re-fires each open. MMKV's
|
|
23
28
|
* `getString`/`set` fit as-is; an in-memory `Map` wrapper works for tests. This
|
|
24
29
|
* is deliberately separate from the async `WireOnboardingStorage` used for
|
|
25
30
|
* session persistence.
|
|
@@ -18,8 +18,13 @@ interface TargetRect {
|
|
|
18
18
|
}
|
|
19
19
|
/**
|
|
20
20
|
* The host-injected gate storage. SYNCHRONOUS on purpose — a coachmark's
|
|
21
|
-
* "seen?" gate must resolve during render
|
|
22
|
-
*
|
|
21
|
+
* "seen?" gate must resolve during render. This is ENFORCED, fail closed: a
|
|
22
|
+
* probe on first read catches an adapter whose `getItem` returns a thenable
|
|
23
|
+
* (AsyncStorage handed straight in) and substitutes a closed adapter — every
|
|
24
|
+
* gate reads "seen", nothing shows, nothing fires, one dev warning names the
|
|
25
|
+
* fix (see `validateGateStorage` in ./runtime). A raw async adapter would
|
|
26
|
+
* otherwise report every gate unseen forever: tours replay each open, and a
|
|
27
|
+
* review gate with `minSessions` 0/1 re-fires each open. MMKV's
|
|
23
28
|
* `getString`/`set` fit as-is; an in-memory `Map` wrapper works for tests. This
|
|
24
29
|
* is deliberately separate from the async `WireOnboardingStorage` used for
|
|
25
30
|
* session persistence.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { O as OnboardingTheme } from './types-BKfpdZzX.js';
|
|
2
|
-
import { C as CoachmarkStorage } from './types-
|
|
2
|
+
import { C as CoachmarkStorage } from './types-D_0B0yay.js';
|
|
3
3
|
import { a as WireFeatures, b as WireFeaturesConfig } from './types-BpwiRpA8.js';
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -25,20 +25,28 @@ import { a as WireFeatures, b as WireFeaturesConfig } from './types-BpwiRpA8.js'
|
|
|
25
25
|
*
|
|
26
26
|
* `submitReview` was a boolean for exactly one unpublished release, and the boolean WAS the bug.
|
|
27
27
|
* `false` meant both "nothing reached the server" and "the server answered non-2xx", and the caller
|
|
28
|
-
* treats `false` as "still owed" and re-posts.
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
28
|
+
* treats `false` as "still owed" and re-posts. At the time neither body carried a client id, so
|
|
29
|
+
* there was NO idempotency key on the wire on either surface: a 502 returned AFTER the insert
|
|
30
|
+
* commits meant the re-post wrote a SECOND row. A response of any status proves the server was
|
|
31
|
+
* reached, and that is a different question from whether it liked the payload — which is why the
|
|
32
|
+
* three values stay, on both surfaces, whatever the wire gains.
|
|
33
33
|
*
|
|
34
34
|
* ── THE RESIDUAL, STATED HONESTLY ─────────────────────────────────────────────────────────────
|
|
35
35
|
*
|
|
36
36
|
* `unsent` is not proof the server never got it. A connection dropped after the request was written
|
|
37
37
|
* — or after the row committed — surfaces as a thrown/rejected `fetch`, exactly like an offline
|
|
38
|
-
* device.
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
38
|
+
* device. Closing that window needs a client-minted idempotency key the server upserts on, and AS
|
|
39
|
+
* OF 2026-08-19 THE TWO SURFACES DIFFER — do not read this paragraph as one fact about both:
|
|
40
|
+
*
|
|
41
|
+
* • REVIEWS — closed. `CreateReviewRequest` takes an optional `idempotency_key` and the server
|
|
42
|
+
* upserts a derived row on it, so a `ReviewGate` re-post merges instead of duplicating. It is a
|
|
43
|
+
* capability, not a guarantee: the server honours the key only when the post also carries a
|
|
44
|
+
* unit to scope it to (`meta.device_key`, else `session_id`), so a caller that strips both is
|
|
45
|
+
* back to the old behaviour.
|
|
46
|
+
* • QUESTIONNAIRES — still open, and still exactly as this paragraph used to read for both. The
|
|
47
|
+
* response body carries no client id and the server has no idempotency path for it at all, so
|
|
48
|
+
* retrying only on `unsent` there is SAFER, not SAFE. Prefer losing a row over inventing one: a
|
|
49
|
+
* lost detractor is a gap in the data, a duplicated one is a lie in the data.
|
|
42
50
|
*/
|
|
43
51
|
type SubmitResult = "accepted" | "rejected" | "unsent";
|
|
44
52
|
|
|
@@ -119,6 +127,13 @@ interface ReviewSubmission {
|
|
|
119
127
|
contact?: string;
|
|
120
128
|
session_id?: string;
|
|
121
129
|
source?: string;
|
|
130
|
+
/**
|
|
131
|
+
* OPTIONAL client-minted key for the two-phase submit: two posts carrying the same key upsert ONE
|
|
132
|
+
* row instead of creating two. The server honours it ONLY when the post also carries a resolvable
|
|
133
|
+
* user — `meta.device_key`, else `session_id` — and silently ignores it otherwise, so it always
|
|
134
|
+
* travels with one of those. One key per gate impression, stable across that impression's posts.
|
|
135
|
+
*/
|
|
136
|
+
idempotency_key?: string;
|
|
122
137
|
meta?: Record<string, unknown>;
|
|
123
138
|
}
|
|
124
139
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wireai/activation",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Premium, fully-themable drop-in AI onboarding kit for React Native / Expo, on top of the open-source wireai-rn SDK.",
|
|
6
6
|
"author": "Malik Chohra <malik@getwireai.com>",
|
package/src/WireOnboarding.tsx
CHANGED
|
@@ -135,7 +135,10 @@ export const WireOnboarding: React.FC<WireOnboardingProps> = ({
|
|
|
135
135
|
// per-install `device_key` the analytics surfaces auto-mint and persist — so the default wiring
|
|
136
136
|
// produces a joined funnel instead of a silent zero. Three conditions, all necessary:
|
|
137
137
|
//
|
|
138
|
-
// • `missingJoinKey` — a host-supplied key ALWAYS wins, verbatim, and is never touched.
|
|
138
|
+
// • `missingJoinKey` — a host-supplied key ALWAYS wins, verbatim, and is never touched. What
|
|
139
|
+
// COUNTS as supplied is `resolveIdentity`'s predicate above: a string with at least one
|
|
140
|
+
// non-whitespace character. A non-string or an all-whitespace `device_key` yields no record,
|
|
141
|
+
// so it reads as absent here and the auto key is merged over it.
|
|
139
142
|
// • `autoJoinKey !== false` — the documented opt-out for a host that genuinely wants an
|
|
140
143
|
// unlinked onboarding session; opting out restores the pre-0.12.2 behavior exactly.
|
|
141
144
|
// • `storage` that actually WORKS — without persistence the auto id is process-scoped, so every
|
|
@@ -27,6 +27,9 @@
|
|
|
27
27
|
*/
|
|
28
28
|
import type { CoachmarkStorage } from "./types";
|
|
29
29
|
|
|
30
|
+
/** RN sets this global; absent under node/SSR. Read defensively via the async-adapter warn below. */
|
|
31
|
+
declare const __DEV__: boolean | undefined;
|
|
32
|
+
|
|
30
33
|
/**
|
|
31
34
|
* Well-known key into the runtime-global symbol registry — one coachmark runtime across bundles.
|
|
32
35
|
*
|
|
@@ -166,6 +169,104 @@ export const showcaseGateKey = (showcaseId: string): string =>
|
|
|
166
169
|
/** Marker value written for a seen gate. */
|
|
167
170
|
const SEEN_VALUE = "1";
|
|
168
171
|
|
|
172
|
+
/** Key used only by the one-off synchronicity probe below. Never carries a meaningful value. */
|
|
173
|
+
const GATE_PROBE_KEY = "wire_gate_probe";
|
|
174
|
+
|
|
175
|
+
/** Once-per-process latch on its own `Symbol.for` slot, mirroring `gateStorageWarned` next door
|
|
176
|
+
* in reviews/runtime: a plain `let` would warn once per inlined bundle copy, and the gates live
|
|
177
|
+
* in three of them.
|
|
178
|
+
*
|
|
179
|
+
* @globalSlot LATCH — written `true` by the first probe that caught an ASYNC gate adapter, and
|
|
180
|
+
* never again. A second differing write re-arms a warning the process already spent, so a host
|
|
181
|
+
* whose broken adapter reaches three gates gets the same paragraph three times and stops reading. */
|
|
182
|
+
const ASYNC_GATE_STORAGE_WARNED_SLOT: unique symbol = Symbol.for(
|
|
183
|
+
"@wireai/activation:asyncGateStorageWarned",
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
type GlobalWithAsyncGateWarn = typeof globalThis & {
|
|
187
|
+
[ASYNC_GATE_STORAGE_WARNED_SLOT]?: boolean;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const asyncGateWarnGlobal = globalThis as GlobalWithAsyncGateWarn;
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The substitute for an adapter whose `getItem` returned a thenable (an ASYNC adapter, e.g.
|
|
194
|
+
* AsyncStorage handed straight in). Every seen-gate reads SEEN and every write is dropped, so
|
|
195
|
+
* nothing shows and nothing fires — FAIL CLOSED, deliberately in both directions the async bug
|
|
196
|
+
* could err: a Promise is never `=== "1"`, so a raw async adapter reports every gate UNSEEN
|
|
197
|
+
* forever — tours and showcases replay on every open, and a review gate with `minSessions` 0/1
|
|
198
|
+
* RE-FIRES its prompt each open (the 2026-07-16 one-star incident class). Never-fire is the only
|
|
199
|
+
* direction a once-per-user gate may err in.
|
|
200
|
+
*/
|
|
201
|
+
const closedGateStorage: CoachmarkStorage = Object.freeze({
|
|
202
|
+
getItem: (): string => SEEN_VALUE,
|
|
203
|
+
setItem: (): void => {},
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
/** Adapters probed sync (or throwing, which every reader already tolerates): pass through. */
|
|
207
|
+
const syncGateAdapters = new WeakSet<CoachmarkStorage>();
|
|
208
|
+
/** Adapters caught returning a thenable: substituted, and never probed again. */
|
|
209
|
+
const asyncGateAdapters = new WeakSet<CoachmarkStorage>();
|
|
210
|
+
|
|
211
|
+
/** Warn once, in dev builds only, when a gate adapter turned out to be async. */
|
|
212
|
+
const warnAsyncGateStorage = (): void => {
|
|
213
|
+
if (asyncGateWarnGlobal[ASYNC_GATE_STORAGE_WARNED_SLOT]) return;
|
|
214
|
+
if (typeof __DEV__ === "undefined" || !__DEV__) return;
|
|
215
|
+
if (typeof console === "undefined" || !console.warn) return;
|
|
216
|
+
asyncGateWarnGlobal[ASYNC_GATE_STORAGE_WARNED_SLOT] = true;
|
|
217
|
+
console.warn(
|
|
218
|
+
"[wireai] the gate storage's getItem returned a Promise — an async adapter (e.g. AsyncStorage) " +
|
|
219
|
+
"cannot drive the once-per-user gates, so coachmark tours, showcases and the review/questionnaire " +
|
|
220
|
+
"gates are all DISABLED (fail closed: nothing shows, nothing fires) until a synchronous adapter " +
|
|
221
|
+
"is provided. Pass a sync adapter (e.g. MMKV: `{ getItem: (k) => mmkv.getString(k) ?? null, " +
|
|
222
|
+
"setItem: (k, v) => mmkv.set(k, v) }`) to CoachmarkProvider or the gate's `storage` prop.",
|
|
223
|
+
);
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
/** Test-only: forget the once-per-process async-adapter warn latch. */
|
|
227
|
+
export const resetAsyncGateStorageWarning = (): void => {
|
|
228
|
+
asyncGateWarnGlobal[ASYNC_GATE_STORAGE_WARNED_SLOT] = undefined;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The thenable probe (fail closed): resolve an adapter to itself when its `getItem` is
|
|
233
|
+
* synchronous, and to `closedGateStorage` when it returns a thenable. Called on every gate READ
|
|
234
|
+
* rather than at injection, so (a) a host handing storage straight to a gate's `storage` prop is
|
|
235
|
+
* probed too, and (b) `CoachmarkProvider`'s unmount cleanup — which compares the singleton to its
|
|
236
|
+
* own prop by IDENTITY — keeps seeing the object the host passed. Verdicts are WeakSet-cached, so
|
|
237
|
+
* each adapter pays for exactly one probe read.
|
|
238
|
+
*/
|
|
239
|
+
export const validateGateStorage = (
|
|
240
|
+
storage: CoachmarkStorage | null,
|
|
241
|
+
): CoachmarkStorage | null => {
|
|
242
|
+
if (!storage) return null;
|
|
243
|
+
if (syncGateAdapters.has(storage)) return storage;
|
|
244
|
+
if (asyncGateAdapters.has(storage)) return closedGateStorage;
|
|
245
|
+
let probed: unknown;
|
|
246
|
+
try {
|
|
247
|
+
probed = storage.getItem(GATE_PROBE_KEY);
|
|
248
|
+
} catch {
|
|
249
|
+
// A throwing adapter is synchronous, which is all the probe rules on; every reader already
|
|
250
|
+
// tolerates the throw itself.
|
|
251
|
+
syncGateAdapters.add(storage);
|
|
252
|
+
return storage;
|
|
253
|
+
}
|
|
254
|
+
if (probed != null && typeof (probed as { then?: unknown }).then === "function") {
|
|
255
|
+
asyncGateAdapters.add(storage);
|
|
256
|
+
try {
|
|
257
|
+
// Detach the probe's promise so an adapter that REJECTS (e.g. a broken native store) cannot
|
|
258
|
+
// surface as an unhandled rejection the host never scheduled.
|
|
259
|
+
(probed as PromiseLike<unknown>).then(undefined, () => {});
|
|
260
|
+
} catch {
|
|
261
|
+
// A hostile thenable whose `then` throws is still async; the verdict stands.
|
|
262
|
+
}
|
|
263
|
+
warnAsyncGateStorage();
|
|
264
|
+
return closedGateStorage;
|
|
265
|
+
}
|
|
266
|
+
syncGateAdapters.add(storage);
|
|
267
|
+
return storage;
|
|
268
|
+
};
|
|
269
|
+
|
|
169
270
|
/**
|
|
170
271
|
* Read a seen gate. Testing mode ALWAYS reports unseen (so everything replays).
|
|
171
272
|
* A missing storage (no provider) also reports unseen — the safe default is to
|
|
@@ -179,7 +280,7 @@ export const hasSeenGate = (
|
|
|
179
280
|
testingOverride?: boolean,
|
|
180
281
|
): boolean => {
|
|
181
282
|
if (testingOverride ?? isCoachmarkTesting()) return false;
|
|
182
|
-
const storage = storageOverride ?? getCoachmarkStorage();
|
|
283
|
+
const storage = validateGateStorage(storageOverride ?? getCoachmarkStorage());
|
|
183
284
|
if (!storage) return false;
|
|
184
285
|
try {
|
|
185
286
|
return storage.getItem(key) === SEEN_VALUE;
|
|
@@ -198,7 +299,7 @@ export const markSeenGate = (
|
|
|
198
299
|
testingOverride?: boolean,
|
|
199
300
|
): void => {
|
|
200
301
|
if (testingOverride ?? isCoachmarkTesting()) return;
|
|
201
|
-
const storage = storageOverride ?? getCoachmarkStorage();
|
|
302
|
+
const storage = validateGateStorage(storageOverride ?? getCoachmarkStorage());
|
|
202
303
|
if (!storage) return;
|
|
203
304
|
try {
|
|
204
305
|
storage.setItem(key, SEEN_VALUE);
|
package/src/coachmarks/types.ts
CHANGED
|
@@ -31,8 +31,13 @@ export interface TargetRect {
|
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
33
|
* The host-injected gate storage. SYNCHRONOUS on purpose — a coachmark's
|
|
34
|
-
* "seen?" gate must resolve during render
|
|
35
|
-
*
|
|
34
|
+
* "seen?" gate must resolve during render. This is ENFORCED, fail closed: a
|
|
35
|
+
* probe on first read catches an adapter whose `getItem` returns a thenable
|
|
36
|
+
* (AsyncStorage handed straight in) and substitutes a closed adapter — every
|
|
37
|
+
* gate reads "seen", nothing shows, nothing fires, one dev warning names the
|
|
38
|
+
* fix (see `validateGateStorage` in ./runtime). A raw async adapter would
|
|
39
|
+
* otherwise report every gate unseen forever: tours replay each open, and a
|
|
40
|
+
* review gate with `minSessions` 0/1 re-fires each open. MMKV's
|
|
36
41
|
* `getString`/`set` fit as-is; an in-memory `Map` wrapper works for tests. This
|
|
37
42
|
* is deliberately separate from the async `WireOnboardingStorage` used for
|
|
38
43
|
* session persistence.
|
package/src/context/deviceId.ts
CHANGED
|
@@ -291,3 +291,35 @@ export const resetAutoDeviceKeys = (): void => {
|
|
|
291
291
|
registry.hydrating.clear();
|
|
292
292
|
registry.pending?.clear();
|
|
293
293
|
};
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* The auto `device_key` a surface with NO `appId` and NO `storage` of its own may stamp onto a
|
|
297
|
+
* payload — the review gate being the one that exists (`ReviewGate` takes neither prop).
|
|
298
|
+
*
|
|
299
|
+
* WHY IT IS NOT JUST `resolveAutoDeviceKey()`. That function is keyed by `appId`, and `appId` is a
|
|
300
|
+
* REQUIRED field on `WireOnboardingConfig` that real hosts set to a real value. A propless caller
|
|
301
|
+
* can only pass `undefined`, which addresses the `"default"` slot — so on any host that names its
|
|
302
|
+
* app, a bare call would MINT a second id beside the one every other surface already shares. That is
|
|
303
|
+
* the halved-counter defect this registry exists to close (see the registry note above), arrived at
|
|
304
|
+
* from the other direction.
|
|
305
|
+
*
|
|
306
|
+
* So this READS and does not address:
|
|
307
|
+
* • exactly one id space in the process → that id, whoever registered it. This is every
|
|
308
|
+
* single-tenant app, i.e. every real app, and it is how the gate joins the analytics surfaces.
|
|
309
|
+
* • none at all → mint one through {@link resolveAutoDeviceKey}. Nothing exists to collide with,
|
|
310
|
+
* so `"default"` is not a second space, it is the first. PROCESS-scoped (there is no storage to
|
|
311
|
+
* persist through), which is honest and still resolvable: a review row is written once per user,
|
|
312
|
+
* so a per-launch value here cannot corrupt a counter the way it corrupts `min_sessions`.
|
|
313
|
+
* • two or more → `undefined`. Two tenants in one process must never share an id, and a caller
|
|
314
|
+
* with no `appId` cannot say which one it belongs to. Refusing is the only safe answer;
|
|
315
|
+
* guessing would stamp one tenant's device onto the other's row.
|
|
316
|
+
*
|
|
317
|
+
* Never throws, never mints a SECOND id, and never overrides anything a host supplied — the caller
|
|
318
|
+
* checks for a host value first.
|
|
319
|
+
*/
|
|
320
|
+
export const ambientAutoDeviceKey = (): string | undefined => {
|
|
321
|
+
const registry = autoDeviceKeyRegistry();
|
|
322
|
+
if (registry.keys.size === 0) return resolveAutoDeviceKey();
|
|
323
|
+
if (registry.keys.size > 1) return undefined;
|
|
324
|
+
return [...registry.keys.values()][0];
|
|
325
|
+
};
|