@envive-ai/react-hooks 0.3.62 → 0.3.63

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.
Files changed (72) hide show
  1. package/dist/application/utils/elementObserver.d.cts +2 -2
  2. package/dist/application/utils/widgetTextRequestToApiRequest.cjs +1 -0
  3. package/dist/application/utils/widgetTextRequestToApiRequest.js +1 -1
  4. package/dist/atoms/app/index.d.cts +7 -7
  5. package/dist/atoms/chat/chatState.d.cts +19 -19
  6. package/dist/atoms/chat/chatState.d.ts +1 -1
  7. package/dist/atoms/chat/form.d.cts +2 -2
  8. package/dist/atoms/chat/index.d.cts +2 -2
  9. package/dist/atoms/chat/lastMessage.d.cts +2 -2
  10. package/dist/atoms/chat/messageQueue.d.cts +6 -6
  11. package/dist/atoms/chat/performanceMetrics.d.cts +6 -6
  12. package/dist/atoms/chat/renderedWidgetRefs.d.cts +2 -2
  13. package/dist/atoms/chat/replies.d.cts +3 -3
  14. package/dist/atoms/chat/replies.d.ts +1 -1
  15. package/dist/atoms/chat/suggestions.d.cts +2 -2
  16. package/dist/atoms/envive/enviveConfig.d.cts +14 -14
  17. package/dist/atoms/envive/enviveConfig.d.ts +1 -1
  18. package/dist/atoms/globalSearch/globalSearch.d.cts +5 -5
  19. package/dist/atoms/globalSearch/globalSearch.d.ts +5 -5
  20. package/dist/atoms/org/customerService.d.cts +6 -6
  21. package/dist/atoms/org/customerService.d.ts +6 -6
  22. package/dist/atoms/org/graphqlConfig.d.cts +4 -4
  23. package/dist/atoms/org/graphqlConfig.d.ts +4 -4
  24. package/dist/atoms/org/newOrgConfigAtom.d.cts +2 -2
  25. package/dist/atoms/org/newOrgConfigAtom.d.ts +2 -2
  26. package/dist/atoms/org/orgAnalyticsConfig.d.cts +4 -4
  27. package/dist/atoms/org/orgAnalyticsConfig.d.ts +4 -4
  28. package/dist/atoms/search/chatSearch.d.cts +17 -17
  29. package/dist/atoms/search/chatSearch.d.ts +17 -17
  30. package/dist/atoms/search/searchAPI.d.cts +13 -13
  31. package/dist/atoms/search/types.d.cts +1 -1
  32. package/dist/atoms/search/utils.d.cts +1 -1
  33. package/dist/atoms/search/utils.d.ts +1 -1
  34. package/dist/atoms/widget/chatPreviewLoading.d.cts +2 -2
  35. package/dist/atoms/widget/chatPreviewLoading.d.ts +2 -2
  36. package/dist/contexts/systemSettingsContext/systemSettingsContext.d.cts +2 -2
  37. package/dist/contexts/types.d.cts +1 -1
  38. package/dist/contexts/types.d.ts +1 -1
  39. package/dist/contexts/typesV3.d.cts +1 -1
  40. package/dist/contexts/typesV3.d.ts +1 -1
  41. package/dist/hooks/utils.d.cts +1 -1
  42. package/dist/hooks/utils.d.ts +1 -1
  43. package/dist/services/enviveConfigService/enviveConfigService.cjs +49 -3
  44. package/dist/services/enviveConfigService/enviveConfigService.d.cts +16 -2
  45. package/dist/services/enviveConfigService/enviveConfigService.d.ts +16 -2
  46. package/dist/services/enviveConfigService/enviveConfigService.js +49 -3
  47. package/dist/services/enviveConfigService/fetchBootstrapConfig.cjs +102 -0
  48. package/dist/services/enviveConfigService/fetchBootstrapConfig.js +102 -0
  49. package/dist/services/enviveConfigService/fetchGraphQLConfig.cjs +2 -1
  50. package/dist/services/enviveConfigService/fetchGraphQLConfig.js +2 -2
  51. package/dist/services/hardcopyService/hardcopyService.cjs +20 -4
  52. package/dist/services/hardcopyService/hardcopyService.d.cts +3 -2
  53. package/dist/services/hardcopyService/hardcopyService.d.ts +3 -2
  54. package/dist/services/hardcopyService/hardcopyService.js +20 -5
  55. package/dist/services/hardcopyService/index.cjs +1 -0
  56. package/dist/services/hardcopyService/index.d.cts +2 -2
  57. package/dist/services/hardcopyService/index.d.ts +2 -2
  58. package/dist/services/hardcopyService/index.js +2 -2
  59. package/dist/services/urlResolverWarmupService/index.cjs +3 -3
  60. package/dist/services/urlResolverWarmupService/index.d.cts +8 -2
  61. package/dist/services/urlResolverWarmupService/index.d.ts +8 -2
  62. package/dist/services/urlResolverWarmupService/index.js +3 -3
  63. package/package.json +1 -1
  64. package/src/services/enviveConfigService/__tests__/enviveConfigService.test.ts +226 -0
  65. package/src/services/enviveConfigService/__tests__/fetchBootstrapConfig.test.ts +271 -0
  66. package/src/services/enviveConfigService/enviveConfigService.ts +124 -5
  67. package/src/services/enviveConfigService/fetchBootstrapConfig.ts +276 -0
  68. package/src/services/enviveConfigService/fetchGraphQLConfig.ts +7 -4
  69. package/src/services/hardcopyService/__tests__/hardcopyService.test.ts +60 -1
  70. package/src/services/hardcopyService/hardcopyService.ts +45 -3
  71. package/src/services/urlResolverWarmupService/__tests__/urlResolverWarmupService.test.ts +55 -0
  72. package/src/services/urlResolverWarmupService/index.ts +18 -1
@@ -2,7 +2,15 @@ import { OrgConfigExperimentAssignment, OrgConfigFeatureGate } from 'src/applica
2
2
  import { FeatureGates } from 'src/application/models/featureGates';
3
3
  import type { GraphQlConfigValues } from 'src/contexts/graphqlContext';
4
4
  import { configVersionOverride } from 'src/types/config-versions';
5
+ import { FeatureFlagService } from 'src/services/featureFlagService';
6
+ import Logger from 'src/application/logging/logger';
7
+ import type { UrlResolverResponse } from 'src/atoms/app/variant';
8
+ import { getAtomStore } from 'src/atoms/atomStore/atomStore';
9
+ import { chatIdAtom } from 'src/atoms/app';
5
10
  import { fetchUnifiedGraphQLConfig } from './fetchGraphQLConfig';
11
+ import { fetchBootstrapConfig } from './fetchBootstrapConfig';
12
+
13
+ const logger = new Logger('enviveConfigService');
6
14
 
7
15
  // Short-lived so config changes (gates, experiments) still propagate within
8
16
  // a session, while sparing every page navigation from re-fetching org
@@ -21,6 +29,12 @@ type EnviveConfigServiceProps = {
21
29
  userId: string;
22
30
  namespace: string;
23
31
  source: string;
32
+ // The url_resolving context bits the hooks layer can't derive itself, supplied
33
+ // by the injection bundle so getEnviveConfig can call /v1/session/bootstrap on
34
+ // the use_unified_config gate-ON path. Optional: when absent (mocks, non-bundle
35
+ // callers) the bootstrap branch is skipped and getWidgetConfig stands.
36
+ env?: string;
37
+ contextSource?: string;
24
38
  };
25
39
 
26
40
  export type EnviveServiceConfig = {
@@ -36,9 +50,10 @@ export type EnviveServiceConfig = {
36
50
  };
37
51
  };
38
52
  gates: OrgConfigFeatureGate[];
39
- // Populated by the backend when the `use_unified_config` Statsig gate
40
- // evaluates true. amplitudeService reads these to enrich events with
41
- // `experiment.{ns}.*` properties.
53
+ // The user's Statsig experiment-layer assignments, from the widget config's
54
+ // resolved experiments getWidgetConfig, or bootstrap's `widget_config`
55
+ // (both ungated by use_unified_config; empty only under a version pin).
56
+ // amplitudeService reads these to enrich events with `experiment.{ns}.*` props.
42
57
  experiment_assignments?: OrgConfigExperimentAssignment[];
43
58
  // orgConfig.resolution is populated only on the unified path (sourced from
44
59
  // getWidgetConfig.resolution, not /v1/org/config.resolution) so the version
@@ -51,8 +66,18 @@ export interface IEnviveConfigService {
51
66
  readonly baseUrl: string;
52
67
  readonly apiKey: string;
53
68
  getEnviveConfig(): Promise<EnviveServiceConfig>;
69
+ // NB: `takeBootstrapUrlResolving` is intentionally NOT on this interface. It's
70
+ // a bootstrap-only detail consumed solely by the injection bundle via the
71
+ // concrete EnviveConfigService; keeping it off the public DI contract avoids a
72
+ // breaking change for external consumers that supply their own config service
73
+ // (EnviveProvider only ever calls getEnviveConfig / baseUrl / apiKey).
54
74
  }
55
75
 
76
+ // The bootstrap url_resolving payload plus the URL it was resolved for, so the
77
+ // warm-up can verify the URL still matches before seeding (see F1: a SPA nav
78
+ // during the bootstrap round trip must not cache URL A's result under URL B).
79
+ type BootstrapUrlResolving = { url: string; response: UrlResolverResponse };
80
+
56
81
  export class EnviveConfigService implements IEnviveConfigService {
57
82
  public readonly baseUrl: string;
58
83
 
@@ -64,14 +89,40 @@ export class EnviveConfigService implements IEnviveConfigService {
64
89
 
65
90
  private readonly source: string;
66
91
 
92
+ private readonly env?: string;
93
+
94
+ private readonly contextSource?: string;
95
+
67
96
  private response: EnviveServiceConfig | null = null;
68
97
 
69
- constructor({ baseUrl, apiKey, userId, namespace, source }: EnviveConfigServiceProps) {
98
+ private bootstrapUrlResolving: BootstrapUrlResolving | null = null;
99
+
100
+ constructor({
101
+ baseUrl,
102
+ apiKey,
103
+ userId,
104
+ namespace,
105
+ source,
106
+ env,
107
+ contextSource,
108
+ }: EnviveConfigServiceProps) {
70
109
  this.baseUrl = baseUrl;
71
110
  this.apiKey = apiKey;
72
111
  this.userId = userId;
73
112
  this.namespace = namespace;
74
113
  this.source = source;
114
+ this.env = env;
115
+ this.contextSource = contextSource;
116
+ }
117
+
118
+ // Hands off (once) the url_resolving payload that /v1/session/bootstrap
119
+ // returned alongside the config — paired with the URL it was resolved for — so
120
+ // the warm-up seeds urlResolverAtom with it instead of firing a separate
121
+ // /v1/url_resolving. null on the getWidgetConfig / cached path.
122
+ takeBootstrapUrlResolving(): BootstrapUrlResolving | null {
123
+ const value = this.bootstrapUrlResolving;
124
+ this.bootstrapUrlResolving = null;
125
+ return value;
75
126
  }
76
127
 
77
128
  private getCacheKey(): string {
@@ -132,16 +183,84 @@ export class EnviveConfigService implements IEnviveConfigService {
132
183
  // list of gates the app checks, so the resolver evaluates and returns them.
133
184
  const gateNames = Object.values(FeatureGates).map(featureGate => featureGate.toString());
134
185
 
135
- this.response = await fetchUnifiedGraphQLConfig(
186
+ const widgetConfig = await fetchUnifiedGraphQLConfig(
136
187
  this.baseUrl,
137
188
  this.apiKey,
138
189
  this.userId,
139
190
  gateNames,
140
191
  this.source,
141
192
  );
193
+
194
+ this.response = await this.maybeUpgradeToBootstrap(widgetConfig, gateNames);
142
195
  this.writeCachedConfig(this.response);
143
196
  return this.response;
144
197
  }
198
+
199
+ // The unified-config ramp (`use_unified_config` Statsig gate). When the gate
200
+ // is ON for this user, swap the getWidgetConfig result for Matt's
201
+ // /v1/session/bootstrap — the same config PLUS the initial url_resolving in
202
+ // ONE round trip (the consolidation this migration is about). The gate is read
203
+ // off the getWidgetConfig response just fetched, because the FE has no Statsig
204
+ // SDK: pymono evaluates the gate server-side and returns it. `FeatureFlagService`
205
+ // also honors a `?use_unified_config=true` override, so the bootstrap path is
206
+ // testable before the gate ramps.
207
+ //
208
+ // Only runs on the cache-miss path (bootstrap's config is then cached like any
209
+ // other), so a cached load never re-fetches. Needs env + contextSource for the
210
+ // url_resolving context — absent for mocks / non-bundle callers, where the
211
+ // branch is skipped. A bootstrap failure falls back to the getWidgetConfig
212
+ // config already in hand: a bootstrap problem degrades to the proven path
213
+ // instead of aborting injection. At 100% the getWidgetConfig call above is
214
+ // dropped and bootstrap becomes the sole config call (step 8).
215
+ private async maybeUpgradeToBootstrap(
216
+ widgetConfig: EnviveServiceConfig,
217
+ gateNames: string[],
218
+ ): Promise<EnviveServiceConfig> {
219
+ if (this.env === undefined || this.contextSource === undefined) {
220
+ return widgetConfig;
221
+ }
222
+ // Built once: reads the gate (override-aware) AND supplies the override-aware
223
+ // gate map bootstrap's url_resolving needs (matches the legacy resolveUrl path).
224
+ const featureFlagService = new FeatureFlagService(widgetConfig.gates);
225
+ if (!featureFlagService.isFeatureGateEnabled(FeatureGates.UseUnifiedConfig)) {
226
+ return widgetConfig;
227
+ }
228
+
229
+ // Captured once and reused for both the bootstrap request and the stash, so
230
+ // the URL carried to the warm-up is provably the one bootstrap resolved. The
231
+ // warm-up only seeds it if this still matches the current URL (a SPA nav
232
+ // during the round trip would otherwise cache this result under a later URL).
233
+ // Same cleansing as warmUrlResolver so the urlResolverAtom key lines up.
234
+ const cleansedUrl = window.location.href.toLowerCase().trim();
235
+ try {
236
+ const { config, urlResolving } = await fetchBootstrapConfig({
237
+ baseUrl: this.baseUrl,
238
+ apiKey: this.apiKey,
239
+ userId: this.userId,
240
+ source: this.source,
241
+ // Scope org_config.configs to this namespace so bootstrap doesn't return
242
+ // internal-only org configs to the browser (matches the legacy /v1/org/config).
243
+ namespace: this.namespace,
244
+ gateNames,
245
+ url: cleansedUrl,
246
+ orgId: widgetConfig.org.org.id,
247
+ orgShortName: widgetConfig.org.org.short_name,
248
+ contextSource: this.contextSource,
249
+ env: this.env,
250
+ featureGates: featureFlagService.getFeatureFlags(),
251
+ // The persistent chat session id (same source resolveUrl reads) so
252
+ // bootstrap's url_resolving carries it instead of an empty string.
253
+ chatId: getAtomStore().get(chatIdAtom),
254
+ });
255
+ this.bootstrapUrlResolving = urlResolving
256
+ ? { url: cleansedUrl, response: urlResolving }
257
+ : null;
258
+ return config;
259
+ } catch (err) {
260
+ logger.logError('getEnviveConfig | bootstrap upgrade failed, using getWidgetConfig', err);
261
+ return widgetConfig;
262
+ }
263
+ }
145
264
  }
146
265
 
147
266
  /**
@@ -0,0 +1,276 @@
1
+ import Logger from 'src/application/logging/logger';
2
+ import type { UrlResolverResponse } from 'src/atoms/app/variant';
3
+ import {
4
+ ExperimentConfigResolutionMetadata,
5
+ OrgConfigExperimentAssignment,
6
+ OrgConfigFeatureGate,
7
+ } from 'src/application/models/api/orgConfigResults';
8
+ import type { GraphQlConfigValues } from 'src/contexts/graphqlContext';
9
+ import { configVersionOverride } from 'src/types/config-versions';
10
+ import { transformV3ProductsConfig } from './fetchGraphQLConfig';
11
+ // Type-only import — erased at compile time, so this does not create a runtime
12
+ // import cycle with enviveConfigService (which imports fetchBootstrapConfig).
13
+ import type { EnviveServiceConfig } from './enviveConfigService';
14
+
15
+ const logger = new Logger('fetchBootstrapConfig');
16
+
17
+ // The url-resolving context bits the hooks layer can't derive on its own — the
18
+ // injection bundle supplies env (a build-time var) and contextSource. org
19
+ // identity is threaded from the getWidgetConfig response fetched moments earlier
20
+ // (see enviveConfigService: bootstrap only runs on the gate-ON path, which reads
21
+ // the gate off getWidgetConfig first, so org id/short_name are already in hand).
22
+ export interface FetchBootstrapConfigParams {
23
+ baseUrl: string;
24
+ apiKey: string;
25
+ userId: string;
26
+ // Config `source` (Statsig user property), 'app' in the injection bundle.
27
+ source: string;
28
+ // Scopes org_config.configs to this namespace. REQUIRED: without it pymono
29
+ // returns EVERY namespace on the org — including internal-only configs (the AI
30
+ // system prompt, brand-voice instructions, etc.) — in the response the public
31
+ // bundle receives. The legacy /v1/org/config call scoped it the same way.
32
+ namespace: string;
33
+ gateNames: string[];
34
+ // url_resolving inputs:
35
+ url: string;
36
+ orgId: string;
37
+ orgShortName: string;
38
+ // Context `source` for url_resolving ('app' | 'playground'); may differ from
39
+ // the config `source` above (playground = Envive Hub).
40
+ contextSource: string;
41
+ env: string;
42
+ chatId?: string;
43
+ // The override-aware feature gates (FeatureFlagService.getFeatureFlags()), so
44
+ // client-side gate overrides (query/window/localStorage) reach page-variant
45
+ // resolution — same as the legacy /v1/url_resolving request. Bootstrap merges
46
+ // these OVER its own server-resolved gates. Harmlessly ignored by pymono until
47
+ // SessionBootstrapUrlResolvingRequest gains the `feature_gates` field (the
48
+ // schema allows unknown fields; the model is `extra='ignore'`), so the FE can
49
+ // ship ahead of the backend.
50
+ featureGates?: Record<string, boolean>;
51
+ }
52
+
53
+ export interface BootstrapResult {
54
+ config: EnviveServiceConfig;
55
+ // The initial page-variant result, ready to seed urlResolverAtom so the
56
+ // warm-up skips its own /v1/url_resolving call. null when bootstrap's
57
+ // (best-effort) url-resolving branch returned nothing.
58
+ urlResolving: UrlResolverResponse | null;
59
+ }
60
+
61
+ // --- raw wire shapes (POST /v1/session/bootstrap is REST snake_case) ---------
62
+
63
+ interface RawFeatureGate {
64
+ name?: string;
65
+ value?: boolean;
66
+ group_name?: string | null;
67
+ rule_id?: string | null;
68
+ }
69
+
70
+ interface RawExperimentAssignment {
71
+ layer_name?: string;
72
+ namespace?: OrgConfigExperimentAssignment['namespace'];
73
+ allocated_experiment_name?: string | null;
74
+ group_name?: string | null;
75
+ }
76
+
77
+ interface RawResolution {
78
+ base_version?: string;
79
+ base_version_source?: string;
80
+ }
81
+
82
+ interface RawBootstrapResponse {
83
+ org_config?: {
84
+ org?: { org?: Record<string, string | undefined> };
85
+ gates?: RawFeatureGate[];
86
+ };
87
+ widget_config?: {
88
+ products_config?: Parameters<typeof transformV3ProductsConfig>[0];
89
+ // Read from widget_config (not org_config) to match getWidgetConfig — see the
90
+ // mapping below.
91
+ experiment_assignments?: RawExperimentAssignment[];
92
+ resolution?: RawResolution;
93
+ };
94
+ url_resolving?: UrlResolverResponse | null;
95
+ }
96
+
97
+ // gates arrive snake (`group_name`); getWidgetConfig's arrive camel. Only the
98
+ // name/value are used for gate checks — groupName is carried for amplitude.
99
+ const toBootstrapGates = (raw: RawFeatureGate[] | undefined): OrgConfigFeatureGate[] =>
100
+ (raw ?? []).map(g => ({
101
+ name: g.name,
102
+ value: g.value,
103
+ groupName: g.group_name ?? undefined,
104
+ }));
105
+
106
+ // experiment_assignments already match the snake OrgConfigExperimentAssignment
107
+ // wire shape (unlike getWidgetConfig's camel, which needs toExperimentAssignments).
108
+ const toBootstrapAssignments = (
109
+ raw: RawExperimentAssignment[] | undefined,
110
+ ): OrgConfigExperimentAssignment[] =>
111
+ (raw ?? []).map(a => ({
112
+ layer_name: a.layer_name,
113
+ namespace: a.namespace,
114
+ allocated_experiment_name: a.allocated_experiment_name,
115
+ group_name: a.group_name,
116
+ }));
117
+
118
+ // resolution is snake here (REST); getWidgetConfig's is camel (Ariadne).
119
+ const toResolution = (
120
+ raw: RawResolution | undefined,
121
+ ): ExperimentConfigResolutionMetadata | undefined =>
122
+ raw ? { baseVersion: raw.base_version, baseVersionSource: raw.base_version_source } : undefined;
123
+
124
+ // One round trip for the config the widget client needs at init: org identity +
125
+ // gates + experiment assignments + productsConfig/resolution, PLUS the initial
126
+ // url-resolving result — the consolidation of `GET /v1/org/config`, GraphQL
127
+ // `me.getWidgetConfig`, and `POST /v1/url_resolving` behind Matt's endpoint.
128
+ //
129
+ // Raw fetch (the npm client `@spiffy-ai/commerce-api-client` has no bootstrap
130
+ // method, same as getWidgetConfig). Throws on network/HTTP failure/timeout — the
131
+ // caller (enviveConfigService) falls back to the getWidgetConfig config it already
132
+ // holds, so a bootstrap problem degrades to the proven path rather than aborting
133
+ // injection.
134
+ //
135
+ // Bootstrap is an OPTIONAL upgrade that BLOCKS the pre-first-paint critical path
136
+ // (a second call after getWidgetConfig already succeeded), and we already hold a
137
+ // usable getWidgetConfig config to fall back to — so it is tightly bounded. Kept
138
+ // low so a slow/hung endpoint adds at most this much to first paint; on timeout
139
+ // the fetch aborts and we fall back to getWidgetConfig. CALIBRATE against the
140
+ // endpoint's real p99 before ramping the gate (a value below p99 will make normal
141
+ // bootstraps false-abort into the fallback, defeating the consolidation).
142
+ const BOOTSTRAP_TIMEOUT_MS = 1500;
143
+
144
+ export const fetchBootstrapConfig = async ({
145
+ baseUrl,
146
+ apiKey,
147
+ userId,
148
+ source,
149
+ namespace,
150
+ gateNames,
151
+ url,
152
+ orgId,
153
+ orgShortName,
154
+ contextSource,
155
+ env,
156
+ chatId,
157
+ featureGates,
158
+ }: FetchBootstrapConfigParams): Promise<BootstrapResult> => {
159
+ const version = configVersionOverride();
160
+
161
+ // Armed before fetch and kept live across response.json() (a stalled body read
162
+ // must also be interruptible), cleared in finally. On timeout the fetch rejects
163
+ // with an AbortError → maybeUpgradeToBootstrap's catch falls back to getWidgetConfig.
164
+ const controller = new AbortController();
165
+ const timeoutId = setTimeout(() => controller.abort(), BOOTSTRAP_TIMEOUT_MS);
166
+ let result: RawBootstrapResponse;
167
+ try {
168
+ const response = await fetch(`${baseUrl}/v1/session/bootstrap`, {
169
+ method: 'POST',
170
+ headers: {
171
+ 'Content-Type': 'application/json',
172
+ Authorization: `Bearer ${apiKey}`,
173
+ },
174
+ body: JSON.stringify({
175
+ user_id: userId,
176
+ source,
177
+ // Scope org_config.configs to the widget namespace — WITHOUT this, pymono
178
+ // returns every namespace's configs (internal ones included) to the browser.
179
+ namespace,
180
+ include_feature_gates: gateNames,
181
+ version,
182
+ url_resolving: {
183
+ url,
184
+ context: {
185
+ user_id: userId,
186
+ org_id: orgId,
187
+ org_short_name: orgShortName,
188
+ chat_id: chatId ?? '',
189
+ source: contextSource,
190
+ env,
191
+ },
192
+ // Override-aware gates so client gate overrides reach page-variant
193
+ // resolution (bootstrap merges these over its server-resolved gates).
194
+ // Ignored by pymono until the endpoint gains the field — safe to ship early.
195
+ feature_gates: featureGates,
196
+ // The config-version override comes ONLY from the URL query param
197
+ // (configVersionOverride) — the same source getWidgetConfig uses. It must
198
+ // NOT come from getWidgetConfig's resolved version: on the gate-ON path
199
+ // that response is discarded, and bootstrap resolves the config version
200
+ // server-side (scoping url_resolving to that resolved version, if desired,
201
+ // belongs on the server — not a getWidgetConfig dependency here).
202
+ override_config_version: version,
203
+ },
204
+ }),
205
+ signal: controller.signal,
206
+ });
207
+
208
+ if (!response.ok) {
209
+ throw new Error(`Bootstrap config request failed: ${response.statusText}`);
210
+ }
211
+ result = (await response.json()) as RawBootstrapResponse;
212
+ } finally {
213
+ clearTimeout(timeoutId);
214
+ }
215
+
216
+ // A 2xx with a missing/partial payload must NOT silently replace the known-good
217
+ // getWidgetConfig config with empty gates / mock products — that would disable the
218
+ // client session or drop experiments. Throw instead, so maybeUpgradeToBootstrap's
219
+ // catch falls back to the getWidgetConfig result it already holds. `gates` is
220
+ // required as a NON-EMPTY array: we always request the full gateNames list and
221
+ // only reach bootstrap because getWidgetConfig returned real gates, so an
222
+ // empty/absent gates response is degraded → fall back. (A products config that is
223
+ // present-but-malformed still degrades to mock below — only the top-level
224
+ // sections are required here.)
225
+ if (!result.org_config?.org?.org || !result.org_config?.gates?.length || !result.widget_config) {
226
+ throw new Error(
227
+ 'Bootstrap config response missing required sections (org_config.org.org / org_config.gates / widget_config)',
228
+ );
229
+ }
230
+ const orgConfig = result.org_config;
231
+ const widgetConfig = result.widget_config;
232
+ // The guard above guarantees org.org at runtime; the `?? {}` only satisfies the
233
+ // type-narrowing (TS doesn't carry the optional-chain narrowing through the alias).
234
+ const orgOrg = orgConfig.org?.org ?? {};
235
+ const resolution = toResolution(widgetConfig.resolution);
236
+
237
+ // A malformed products config degrades to mock defaults WITHOUT losing the
238
+ // org / gates / experiments that came back fine — mirrors fetchUnifiedGraphQLConfig.
239
+ let orgConfigValues: GraphQlConfigValues;
240
+ try {
241
+ orgConfigValues = { ...transformV3ProductsConfig(widgetConfig.products_config), resolution };
242
+ } catch (err) {
243
+ logger.logError('fetchBootstrapConfig | Error transforming products config', err);
244
+ orgConfigValues = { colorsConfig: undefined, frontendConfig: undefined, resolution };
245
+ }
246
+
247
+ const config: EnviveServiceConfig = {
248
+ // org_config is byte-identical to the legacy /v1/org/config (snake), the
249
+ // exact shape EnviveServiceConfig.org was built to hold. Constructed
250
+ // defensively (like fetchUnifiedGraphQLConfig) rather than passed through.
251
+ org: {
252
+ org: {
253
+ id: orgOrg.id ?? '',
254
+ short_name: orgOrg.short_name ?? '',
255
+ display_name: orgOrg.display_name ?? '',
256
+ domain: orgOrg.domain ?? '',
257
+ status: orgOrg.status ?? '',
258
+ created_at: orgOrg.created_at ?? '',
259
+ updated_at: orgOrg.updated_at ?? '',
260
+ },
261
+ },
262
+ gates: toBootstrapGates(orgConfig.gates),
263
+ // From widget_config, NOT org_config: getWidgetConfig sources assignments from
264
+ // its widget config (ungated), whereas bootstrap's `org_config.experiment_assignments`
265
+ // is populated ONLY when the `use_unified_config` gate evaluates true SERVER-SIDE
266
+ // (pymono `unified_config_active`). Reading org_config would drop assignments —
267
+ // and the amplitude `experiment.{ns}.*` enrichment — on the client-override test
268
+ // path (gate ON via `?use_unified_config=true` but 0% server-side). widget_config
269
+ // matches getWidgetConfig in every case (equal when the gate is on; version pin
270
+ // empties both).
271
+ experiment_assignments: toBootstrapAssignments(widgetConfig.experiment_assignments),
272
+ orgConfig: orgConfigValues,
273
+ };
274
+
275
+ return { config, urlResolving: result.url_resolving ?? null };
276
+ };
@@ -151,10 +151,13 @@ const v3FrontendConfigCleanup = (
151
151
  };
152
152
  };
153
153
 
154
- // Turns a raw productsConfig payload (from getProductsConfigByVersion or
155
- // getWidgetConfig) into the camelCase GraphQlConfigValues shape the rest of
156
- // the toolkit consumes. Returns mock config when colors are absent.
157
- const transformV3ProductsConfig = (
154
+ // Turns a raw productsConfig payload (from getProductsConfigByVersion,
155
+ // getWidgetConfig, or the /v1/session/bootstrap widget_config) into the
156
+ // camelCase GraphQlConfigValues shape the rest of the toolkit consumes.
157
+ // Returns mock config when colors are absent. Exported so fetchBootstrapConfig
158
+ // reuses the exact same transform (bootstrap's products_config carries the same
159
+ // `.{section}.values` shape as getWidgetConfig's productsConfig).
160
+ export const transformV3ProductsConfig = (
158
161
  productsConfig:
159
162
  | {
160
163
  frontend?: { values?: { merchant_override_css?: string } };
@@ -4,7 +4,12 @@ import type { AppDetails } from 'src/atoms/app';
4
4
  import { EnviveMetricsEventName, TrackEventParams } from 'src/services/amplitudeService';
5
5
  import { getAtomStore } from 'src/atoms/atomStore/atomStore';
6
6
  import { resolvedBaseConfigVersionAtom } from 'src/atoms/envive/resolvedBaseConfigVersion';
7
- import { HardcopyService, MOCK_HARDCOPY_RESPONSE, MockHardcopyService } from '../hardcopyService';
7
+ import {
8
+ HARDCOPY_TIMEOUT_MS,
9
+ HardcopyService,
10
+ MOCK_HARDCOPY_RESPONSE,
11
+ MockHardcopyService,
12
+ } from '../hardcopyService';
8
13
 
9
14
  const { MockLogger, mockGetHardcopy, mockGetQueryParam } = vi.hoisted(() => {
10
15
  const MockLogger = vi.fn(function () {
@@ -269,6 +274,38 @@ describe('HardcopyService', () => {
269
274
  );
270
275
  });
271
276
 
277
+ it.each([
278
+ [WidgetTypeV3.ProductCardV3, 'ImagePromptCardV3'],
279
+ [WidgetTypeV3.PromptButtonCarouselWithImageV3, 'SingleImagePromptV3'],
280
+ ])(
281
+ 'stamps the API-facing (translated) widget type on both events: %s → %s',
282
+ async (internalType, wireType) => {
283
+ // The AI-suggestions accounting joins these events against the backend registry,
284
+ // which is keyed by the wire type. Stamping the internal name zeroed the stats
285
+ // for aliased widgets (Naturalizer PLP).
286
+ const trackEvent = vi.fn();
287
+ mockGetHardcopy.mockResolvedValueOnce(makeWidgetTextResponse());
288
+
289
+ await makeService(trackEvent).getHardcopy({
290
+ widgetType: internalType,
291
+ userEvent: {} as any,
292
+ });
293
+
294
+ expect(trackEvent).toHaveBeenCalledWith(
295
+ expect.objectContaining({
296
+ eventName: EnviveMetricsEventName.WidgetTextRequest,
297
+ eventProps: expect.objectContaining({ widget_type: wireType }),
298
+ }),
299
+ );
300
+ expect(trackEvent).toHaveBeenCalledWith(
301
+ expect.objectContaining({
302
+ eventName: EnviveMetricsEventName.WidgetTextResponse,
303
+ eventProps: expect.objectContaining({ widget_type: wireType }),
304
+ }),
305
+ );
306
+ },
307
+ );
308
+
272
309
  it('forwards ai_suggestions_attribution_by_slot on WidgetTextResponse when present', async () => {
273
310
  const trackEvent = vi.fn();
274
311
  const attribution = {
@@ -385,6 +422,28 @@ describe('HardcopyService', () => {
385
422
  }),
386
423
  ).rejects.toThrow('No hardcopy response found for widget type: UnknownWidget');
387
424
  });
425
+
426
+ it('falls back to the mock when the API request hangs past the timeout', async () => {
427
+ // A request that never settles — the timeout is the only thing that can release it.
428
+ // Without it the widget's loading latch never resolves ("PLP staying in loading state").
429
+ vi.useFakeTimers();
430
+ try {
431
+ mockGetHardcopy.mockReturnValueOnce(new Promise(() => {}));
432
+
433
+ const resultPromise = makeService().getHardcopy({
434
+ widgetType: WidgetTypeV3.PromptCarouselV3,
435
+ userEvent: {} as any,
436
+ });
437
+
438
+ await vi.advanceTimersByTimeAsync(HARDCOPY_TIMEOUT_MS);
439
+
440
+ await expect(resultPromise).resolves.toBe(
441
+ MOCK_HARDCOPY_RESPONSE[WidgetTypeV3.PromptCarouselV3],
442
+ );
443
+ } finally {
444
+ vi.useRealTimers();
445
+ }
446
+ });
388
447
  });
389
448
  });
390
449
 
@@ -4,6 +4,7 @@ import { WidgetString, WidgetText } from 'src/application/models/api/widgetText'
4
4
  import { WidgetTextRequest } from 'src/application/models/api/widgetTextRequest';
5
5
  import { toCamelCase } from 'src/application/models/utils/snakeToCamelTransformer';
6
6
  import { getQueryParam } from 'src/application/utils';
7
+ import { coreWidgetTypeToApiWidgetType } from 'src/application/utils/widgetTextRequestToApiRequest';
7
8
  import { UserEvent } from 'src/application/models';
8
9
  import Logger from 'src/application/logging/logger';
9
10
  import { AppDetails } from 'src/atoms/app';
@@ -14,6 +15,30 @@ import { EnviveMetricsEventName, TrackEventParams } from '../amplitudeService';
14
15
 
15
16
  const logger = new Logger('hardcopyService');
16
17
 
18
+ // Longest a hardcopy request may hang before we stop waiting for it. The backend-error
19
+ // path below already falls back to static copy, but a request that never settles at all
20
+ // never reaches that catch — it left the widget's loading latch unresolved forever, which
21
+ // is the "PLP staying in loading state" failure. Racing the request against this timeout
22
+ // turns a hang into the same fallback path an error takes, so the widget always leaves its
23
+ // loading state.
24
+ //
25
+ // Kept under the ~4-5s at which session replays show shoppers abandon a still-loading
26
+ // widget: a rescue that fires after they've left helps no one. When it fires, the catch
27
+ // below logs an error (message includes "timed out") — that log is the signal monitoring
28
+ // can count to measure how often hardcopy stalls.
29
+ export const HARDCOPY_TIMEOUT_MS = 4000;
30
+
31
+ // Reject if `promise` has not settled within `ms`. The underlying request is not aborted
32
+ // (a late response is simply discarded) — the only goal is to stop *waiting*, so the
33
+ // caller's error handling can run.
34
+ const withTimeout = <T>(promise: Promise<T>, ms: number): Promise<T> => {
35
+ let timer: ReturnType<typeof setTimeout>;
36
+ const timeout = new Promise<never>((_, reject) => {
37
+ timer = setTimeout(() => reject(new Error(`hardcopy request timed out after ${ms}ms`)), ms);
38
+ });
39
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)) as Promise<T>;
40
+ };
41
+
17
42
  export interface HardcopyRequest {
18
43
  widgetType: WidgetTypeV3;
19
44
  userEvent: UserEvent;
@@ -218,23 +243,40 @@ export class HardcopyService implements IHardcopyService {
218
243
  url: window.location.href,
219
244
  };
220
245
 
246
+ // The Widget Text events must carry the API-facing (translated) widget type, not the
247
+ // internal one: the AI-suggestions accounting joins these events against the backend
248
+ // registry, which is keyed by the wire type (e.g. ProductCardV3 is sent — and stored —
249
+ // as ImagePromptCardV3). Stamping the internal name here zeroed those stats. An
250
+ // unmapped type keeps the internal name — analytics must never block serving.
251
+ let wireWidgetType: string = request.widgetType;
252
+ try {
253
+ wireWidgetType = coreWidgetTypeToApiWidgetType(request.widgetType);
254
+ } catch {
255
+ logger.logDebug('no wire mapping for widget type — keeping internal name', {
256
+ widgetType: request.widgetType,
257
+ });
258
+ }
259
+
221
260
  this.config.trackEvent?.({
222
261
  eventName: EnviveMetricsEventName.WidgetTextRequest,
223
262
  eventProps: {
224
- widget_type: request.widgetType,
263
+ widget_type: wireWidgetType,
225
264
  request_id: widgetTextRequest.requestId,
226
265
  },
227
266
  });
228
267
 
229
268
  try {
230
- const response = await CommerceApiClient.getHardcopy(widgetTextRequest);
269
+ const response = await withTimeout(
270
+ CommerceApiClient.getHardcopy(widgetTextRequest),
271
+ HARDCOPY_TIMEOUT_MS,
272
+ );
231
273
  if (response) {
232
274
  const convertedResponse = convertToHardcopyResponse(response);
233
275
  logger.logDebug('converted response', convertedResponse);
234
276
  this.config.trackEvent?.({
235
277
  eventName: EnviveMetricsEventName.WidgetTextResponse,
236
278
  eventProps: {
237
- widget_type: request.widgetType,
279
+ widget_type: wireWidgetType,
238
280
  response_id: response.responseId,
239
281
  widget_text: response.values,
240
282
  request_id: widgetTextRequest.requestId,