@envive-ai/react-hooks 0.3.62 → 0.3.64

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 (105) hide show
  1. package/dist/application/models/featureGates.cjs +2 -1
  2. package/dist/application/models/featureGates.d.cts +2 -1
  3. package/dist/application/models/featureGates.d.ts +2 -1
  4. package/dist/application/models/featureGates.js +2 -1
  5. package/dist/application/utils/widgetTextRequestToApiRequest.cjs +1 -0
  6. package/dist/application/utils/widgetTextRequestToApiRequest.js +1 -1
  7. package/dist/atoms/app/variant.d.cts +6 -6
  8. package/dist/atoms/chat/chatState.d.cts +1 -1
  9. package/dist/atoms/chat/chatState.d.ts +19 -19
  10. package/dist/atoms/chat/form.d.cts +2 -2
  11. package/dist/atoms/chat/form.d.ts +2 -2
  12. package/dist/atoms/chat/index.d.cts +2 -2
  13. package/dist/atoms/chat/index.d.ts +3 -3
  14. package/dist/atoms/chat/lastMessage.d.cts +2 -2
  15. package/dist/atoms/chat/lastMessage.d.ts +2 -2
  16. package/dist/atoms/chat/messageQueue.d.cts +7 -7
  17. package/dist/atoms/chat/messageQueue.d.ts +6 -6
  18. package/dist/atoms/chat/performanceMetrics.d.cts +6 -6
  19. package/dist/atoms/chat/performanceMetrics.d.ts +6 -6
  20. package/dist/atoms/chat/renderedWidgetRefs.d.cts +3 -3
  21. package/dist/atoms/chat/renderedWidgetRefs.d.ts +2 -2
  22. package/dist/atoms/chat/replies.d.cts +3 -3
  23. package/dist/atoms/chat/replies.d.ts +3 -3
  24. package/dist/atoms/chat/suggestions.d.cts +3 -3
  25. package/dist/atoms/chat/suggestions.d.ts +2 -2
  26. package/dist/atoms/envive/enviveConfig.d.cts +13 -13
  27. package/dist/atoms/envive/enviveConfig.d.ts +14 -14
  28. package/dist/atoms/globalSearch/globalSearch.d.cts +5 -5
  29. package/dist/atoms/globalSearch/globalSearch.d.ts +5 -5
  30. package/dist/atoms/org/customerService.d.cts +6 -6
  31. package/dist/atoms/org/customerService.d.ts +6 -6
  32. package/dist/atoms/org/graphqlConfig.d.cts +4 -4
  33. package/dist/atoms/org/graphqlConfig.d.ts +4 -4
  34. package/dist/atoms/org/newOrgConfigAtom.d.ts +2 -2
  35. package/dist/atoms/org/orgAnalyticsConfig.d.ts +4 -4
  36. package/dist/atoms/search/chatSearch.d.ts +17 -17
  37. package/dist/atoms/search/searchAPI.d.ts +13 -13
  38. package/dist/atoms/search/utils.d.ts +1 -1
  39. package/dist/atoms/widget/chatPreviewLoading.d.ts +2 -2
  40. package/dist/contexts/systemSettingsContext/systemSettingsContext.d.cts +2 -2
  41. package/dist/contexts/types.d.cts +1 -1
  42. package/dist/contexts/types.d.ts +1 -1
  43. package/dist/hooks/GrabAndScroll/useGrabAndScroll.d.cts +2 -2
  44. package/dist/hooks/Intersection/useIntersection.cjs +9 -2
  45. package/dist/hooks/Intersection/useIntersection.d.cts +2 -2
  46. package/dist/hooks/Intersection/useIntersection.d.ts +2 -2
  47. package/dist/hooks/Intersection/useIntersection.js +9 -2
  48. package/dist/hooks/SystemSettingsContext/useSystemSettingsContext.d.cts +2 -2
  49. package/dist/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.cjs +12 -3
  50. package/dist/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.d.cts +2 -1
  51. package/dist/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.d.ts +2 -1
  52. package/dist/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.js +13 -4
  53. package/dist/hooks/WidgetLoadDiagnostics/index.cjs +4 -0
  54. package/dist/hooks/WidgetLoadDiagnostics/index.d.cts +2 -0
  55. package/dist/hooks/WidgetLoadDiagnostics/index.d.ts +2 -0
  56. package/dist/hooks/WidgetLoadDiagnostics/index.js +3 -0
  57. package/dist/hooks/WidgetLoadDiagnostics/useWidgetLoadDiagnostics.cjs +122 -0
  58. package/dist/hooks/WidgetLoadDiagnostics/useWidgetLoadDiagnostics.d.cts +48 -0
  59. package/dist/hooks/WidgetLoadDiagnostics/useWidgetLoadDiagnostics.d.ts +48 -0
  60. package/dist/hooks/WidgetLoadDiagnostics/useWidgetLoadDiagnostics.js +120 -0
  61. package/dist/services/amplitudeService/eventNames.cjs +2 -1
  62. package/dist/services/amplitudeService/eventNames.d.cts +2 -1
  63. package/dist/services/amplitudeService/eventNames.d.ts +2 -1
  64. package/dist/services/amplitudeService/eventNames.js +2 -1
  65. package/dist/services/enviveConfigService/enviveConfigService.cjs +49 -3
  66. package/dist/services/enviveConfigService/enviveConfigService.d.cts +16 -2
  67. package/dist/services/enviveConfigService/enviveConfigService.d.ts +16 -2
  68. package/dist/services/enviveConfigService/enviveConfigService.js +49 -3
  69. package/dist/services/enviveConfigService/fetchBootstrapConfig.cjs +102 -0
  70. package/dist/services/enviveConfigService/fetchBootstrapConfig.js +102 -0
  71. package/dist/services/enviveConfigService/fetchGraphQLConfig.cjs +2 -1
  72. package/dist/services/enviveConfigService/fetchGraphQLConfig.js +2 -2
  73. package/dist/services/ga4ProjectionService/ga4EventSchema.cjs +3 -2
  74. package/dist/services/ga4ProjectionService/ga4EventSchema.js +3 -2
  75. package/dist/services/hardcopyService/hardcopyService.cjs +31 -6
  76. package/dist/services/hardcopyService/hardcopyService.d.cts +5 -2
  77. package/dist/services/hardcopyService/hardcopyService.d.ts +5 -2
  78. package/dist/services/hardcopyService/hardcopyService.js +31 -7
  79. package/dist/services/hardcopyService/index.cjs +1 -0
  80. package/dist/services/hardcopyService/index.d.cts +2 -2
  81. package/dist/services/hardcopyService/index.d.ts +2 -2
  82. package/dist/services/hardcopyService/index.js +2 -2
  83. package/dist/services/urlResolverWarmupService/index.cjs +3 -3
  84. package/dist/services/urlResolverWarmupService/index.d.cts +8 -2
  85. package/dist/services/urlResolverWarmupService/index.d.ts +8 -2
  86. package/dist/services/urlResolverWarmupService/index.js +3 -3
  87. package/package.json +5 -1
  88. package/src/application/models/featureGates.ts +6 -0
  89. package/src/hooks/Intersection/useIntersection.ts +11 -0
  90. package/src/hooks/TrackComponentVisibleEvent/__tests__/useTrackComponentVisibleEvent.test.tsx +64 -0
  91. package/src/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.ts +23 -4
  92. package/src/hooks/WidgetLoadDiagnostics/__tests__/useWidgetLoadDiagnostics.test.ts +161 -0
  93. package/src/hooks/WidgetLoadDiagnostics/index.ts +5 -0
  94. package/src/hooks/WidgetLoadDiagnostics/useWidgetLoadDiagnostics.ts +178 -0
  95. package/src/services/amplitudeService/eventNames.ts +4 -0
  96. package/src/services/enviveConfigService/__tests__/enviveConfigService.test.ts +226 -0
  97. package/src/services/enviveConfigService/__tests__/fetchBootstrapConfig.test.ts +271 -0
  98. package/src/services/enviveConfigService/enviveConfigService.ts +124 -5
  99. package/src/services/enviveConfigService/fetchBootstrapConfig.ts +276 -0
  100. package/src/services/enviveConfigService/fetchGraphQLConfig.ts +7 -4
  101. package/src/services/ga4ProjectionService/ga4EventSchema.ts +5 -0
  102. package/src/services/hardcopyService/__tests__/hardcopyService.test.ts +73 -3
  103. package/src/services/hardcopyService/hardcopyService.ts +55 -5
  104. package/src/services/urlResolverWarmupService/__tests__/urlResolverWarmupService.test.ts +55 -0
  105. package/src/services/urlResolverWarmupService/index.ts +18 -1
@@ -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 } };
@@ -156,4 +156,9 @@ export const GA4_EVENT_SCHEMA: Record<EnviveMetricsEventName, GA4EventSchemaEntr
156
156
  [EnviveMetricsEventName.WidgetTextClicked]: {
157
157
  gaEventName: null,
158
158
  },
159
+
160
+ // Internal eng operational monitoring — never projected to merchant GA4.
161
+ [EnviveMetricsEventName.Diagnostics]: {
162
+ gaEventName: null,
163
+ },
159
164
  };
@@ -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 () {
@@ -87,6 +92,7 @@ describe('HardcopyService', () => {
87
92
  titleLabel: 'Hello World',
88
93
  promptButtonTexts: ['Prompt 1', 'Prompt 2'],
89
94
  },
95
+ servedFrom: 'api',
90
96
  });
91
97
  });
92
98
 
@@ -269,6 +275,38 @@ describe('HardcopyService', () => {
269
275
  );
270
276
  });
271
277
 
278
+ it.each([
279
+ [WidgetTypeV3.ProductCardV3, 'ImagePromptCardV3'],
280
+ [WidgetTypeV3.PromptButtonCarouselWithImageV3, 'SingleImagePromptV3'],
281
+ ])(
282
+ 'stamps the API-facing (translated) widget type on both events: %s → %s',
283
+ async (internalType, wireType) => {
284
+ // The AI-suggestions accounting joins these events against the backend registry,
285
+ // which is keyed by the wire type. Stamping the internal name zeroed the stats
286
+ // for aliased widgets (Naturalizer PLP).
287
+ const trackEvent = vi.fn();
288
+ mockGetHardcopy.mockResolvedValueOnce(makeWidgetTextResponse());
289
+
290
+ await makeService(trackEvent).getHardcopy({
291
+ widgetType: internalType,
292
+ userEvent: {} as any,
293
+ });
294
+
295
+ expect(trackEvent).toHaveBeenCalledWith(
296
+ expect.objectContaining({
297
+ eventName: EnviveMetricsEventName.WidgetTextRequest,
298
+ eventProps: expect.objectContaining({ widget_type: wireType }),
299
+ }),
300
+ );
301
+ expect(trackEvent).toHaveBeenCalledWith(
302
+ expect.objectContaining({
303
+ eventName: EnviveMetricsEventName.WidgetTextResponse,
304
+ eventProps: expect.objectContaining({ widget_type: wireType }),
305
+ }),
306
+ );
307
+ },
308
+ );
309
+
272
310
  it('forwards ai_suggestions_attribution_by_slot on WidgetTextResponse when present', async () => {
273
311
  const trackEvent = vi.fn();
274
312
  const attribution = {
@@ -346,7 +384,11 @@ describe('HardcopyService', () => {
346
384
  userEvent: {} as any,
347
385
  });
348
386
 
349
- expect(result).toBe(MOCK_HARDCOPY_RESPONSE[WidgetTypeV3.PromptCarouselV3]);
387
+ expect(result).toEqual({
388
+ ...MOCK_HARDCOPY_RESPONSE[WidgetTypeV3.PromptCarouselV3],
389
+ servedFrom: 'static',
390
+ fallbackReason: 'error',
391
+ });
350
392
  });
351
393
 
352
394
  it('returns the MOCK_HARDCOPY_RESPONSE entry when the API returns falsy', async () => {
@@ -357,7 +399,11 @@ describe('HardcopyService', () => {
357
399
  userEvent: {} as any,
358
400
  });
359
401
 
360
- expect(result).toBe(MOCK_HARDCOPY_RESPONSE[WidgetTypeV3.SocialProofV3]);
402
+ expect(result).toEqual({
403
+ ...MOCK_HARDCOPY_RESPONSE[WidgetTypeV3.SocialProofV3],
404
+ servedFrom: 'static',
405
+ fallbackReason: 'error',
406
+ });
361
407
  });
362
408
 
363
409
  it('does not fire WidgetTextResponse when falling back to mock', async () => {
@@ -385,6 +431,30 @@ describe('HardcopyService', () => {
385
431
  }),
386
432
  ).rejects.toThrow('No hardcopy response found for widget type: UnknownWidget');
387
433
  });
434
+
435
+ it('falls back to the mock when the API request hangs past the timeout', async () => {
436
+ // A request that never settles — the timeout is the only thing that can release it.
437
+ // Without it the widget's loading latch never resolves ("PLP staying in loading state").
438
+ vi.useFakeTimers();
439
+ try {
440
+ mockGetHardcopy.mockReturnValueOnce(new Promise(() => {}));
441
+
442
+ const resultPromise = makeService().getHardcopy({
443
+ widgetType: WidgetTypeV3.PromptCarouselV3,
444
+ userEvent: {} as any,
445
+ });
446
+
447
+ await vi.advanceTimersByTimeAsync(HARDCOPY_TIMEOUT_MS);
448
+
449
+ await expect(resultPromise).resolves.toEqual({
450
+ ...MOCK_HARDCOPY_RESPONSE[WidgetTypeV3.PromptCarouselV3],
451
+ servedFrom: 'static',
452
+ fallbackReason: 'timeout',
453
+ });
454
+ } finally {
455
+ vi.useRealTimers();
456
+ }
457
+ });
388
458
  });
389
459
  });
390
460
 
@@ -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;
@@ -24,6 +49,10 @@ export type HardcopyResponse = {
24
49
  language: string;
25
50
  rawValues?: Record<string, WidgetString | WidgetString[]>;
26
51
  values: Record<string, string | string[]>;
52
+ // How the copy was served, for widget-load diagnostics: 'api' = live backend response,
53
+ // 'static' = fell back to the bundled MOCK_HARDCOPY_RESPONSE after a backend error or timeout.
54
+ servedFrom?: 'api' | 'static';
55
+ fallbackReason?: 'timeout' | 'error';
27
56
  };
28
57
 
29
58
  export interface IHardcopyService {
@@ -218,23 +247,42 @@ export class HardcopyService implements IHardcopyService {
218
247
  url: window.location.href,
219
248
  };
220
249
 
250
+ // The Widget Text events must carry the API-facing (translated) widget type, not the
251
+ // internal one: the AI-suggestions accounting joins these events against the backend
252
+ // registry, which is keyed by the wire type (e.g. ProductCardV3 is sent — and stored —
253
+ // as ImagePromptCardV3). Stamping the internal name here zeroed those stats. An
254
+ // unmapped type keeps the internal name — analytics must never block serving.
255
+ let wireWidgetType: string = request.widgetType;
256
+ try {
257
+ wireWidgetType = coreWidgetTypeToApiWidgetType(request.widgetType);
258
+ } catch {
259
+ logger.logDebug('no wire mapping for widget type — keeping internal name', {
260
+ widgetType: request.widgetType,
261
+ });
262
+ }
263
+
221
264
  this.config.trackEvent?.({
222
265
  eventName: EnviveMetricsEventName.WidgetTextRequest,
223
266
  eventProps: {
224
- widget_type: request.widgetType,
267
+ widget_type: wireWidgetType,
225
268
  request_id: widgetTextRequest.requestId,
226
269
  },
227
270
  });
228
271
 
272
+ // Reason the API path failed, surfaced on the static fallback for widget-load diagnostics.
273
+ let fallbackReason: 'timeout' | 'error' | undefined;
229
274
  try {
230
- const response = await CommerceApiClient.getHardcopy(widgetTextRequest);
275
+ const response = await withTimeout(
276
+ CommerceApiClient.getHardcopy(widgetTextRequest),
277
+ HARDCOPY_TIMEOUT_MS,
278
+ );
231
279
  if (response) {
232
280
  const convertedResponse = convertToHardcopyResponse(response);
233
281
  logger.logDebug('converted response', convertedResponse);
234
282
  this.config.trackEvent?.({
235
283
  eventName: EnviveMetricsEventName.WidgetTextResponse,
236
284
  eventProps: {
237
- widget_type: request.widgetType,
285
+ widget_type: wireWidgetType,
238
286
  response_id: response.responseId,
239
287
  widget_text: response.values,
240
288
  request_id: widgetTextRequest.requestId,
@@ -245,16 +293,18 @@ export class HardcopyService implements IHardcopyService {
245
293
  : {}),
246
294
  },
247
295
  });
248
- return convertedResponse;
296
+ return { ...convertedResponse, servedFrom: 'api' };
249
297
  }
250
298
  } catch (error) {
251
299
  logger.logError('error getting hardcopy from backend', error);
300
+ fallbackReason =
301
+ error instanceof Error && error.message.includes('timed out') ? 'timeout' : 'error';
252
302
  }
253
303
 
254
304
  const mockResponse = MOCK_HARDCOPY_RESPONSE[request.widgetType];
255
305
  if (mockResponse) {
256
306
  logger.logDebug('no real response, using mock', mockResponse);
257
- return mockResponse;
307
+ return { ...mockResponse, servedFrom: 'static', fallbackReason: fallbackReason ?? 'error' };
258
308
  }
259
309
  throw new Error(`No hardcopy response found for widget type: ${request.widgetType}`);
260
310
  }
@@ -4,6 +4,7 @@ import { contextSourceAtom, envAtom, enviveConfigAtom } from 'src/atoms/envive/e
4
4
  import { featureFlagServiceAtom } from 'src/atoms/org/graphqlConfig';
5
5
  import { FeatureFlagService } from 'src/services/featureFlagService';
6
6
  import type { EnviveServiceConfig } from 'src/services/enviveConfigService';
7
+ import { type UrlResolverResponse, urlResolverAtom } from 'src/atoms/app/variant';
7
8
  import { warmUrlResolver } from '../index';
8
9
 
9
10
  const { mockResolveUrl } = vi.hoisted(() => ({ mockResolveUrl: vi.fn() }));
@@ -82,4 +83,58 @@ describe('warmUrlResolver', () => {
82
83
  expect(getAtomStore().get(featureFlagServiceAtom)).toBeUndefined();
83
84
  expect(mockResolveUrl).toHaveBeenCalledTimes(1);
84
85
  });
86
+
87
+ it('seeds urlResolverAtom from precomputedUrlResolving when its URL matches, skipping resolveUrl', async () => {
88
+ // The bootstrap-consolidated path: /v1/session/bootstrap already resolved
89
+ // the URL, so the warm-up must NOT fire a second /v1/url_resolving.
90
+ const response = {
91
+ variant_type: 'pdp',
92
+ specific_details: {},
93
+ ready: true,
94
+ } as unknown as UrlResolverResponse;
95
+ const cleansedUrl = window.location.href.toLowerCase().trim();
96
+
97
+ await warmUrlResolver({
98
+ ...baseParams,
99
+ env: 'prod',
100
+ contextSource: 'app',
101
+ featureFlagService: new FeatureFlagService([]),
102
+ precomputedUrlResolving: { url: cleansedUrl, response },
103
+ });
104
+
105
+ expect(mockResolveUrl).not.toHaveBeenCalled();
106
+ expect(getAtomStore().get(urlResolverAtom)[cleansedUrl]).toEqual(response);
107
+ });
108
+
109
+ it('falls back to resolveUrl when the precomputed URL no longer matches (SPA nav during bootstrap)', async () => {
110
+ // Bootstrap resolved a DIFFERENT URL (a nav happened since) — its result must
111
+ // NOT be cached under the current URL; resolve the current URL instead.
112
+ const staleResponse = {
113
+ variant_type: 'plp',
114
+ specific_details: {},
115
+ ready: true,
116
+ } as unknown as UrlResolverResponse;
117
+ mockResolveUrl.mockResolvedValue({ variant_type: 'pdp', ready: true });
118
+
119
+ await warmUrlResolver({
120
+ ...baseParams,
121
+ env: 'prod',
122
+ contextSource: 'app',
123
+ featureFlagService: new FeatureFlagService([]),
124
+ precomputedUrlResolving: { url: 'https://other.example/stale', response: staleResponse },
125
+ });
126
+
127
+ expect(mockResolveUrl).toHaveBeenCalledTimes(1);
128
+ const cleansedUrl = window.location.href.toLowerCase().trim();
129
+ expect(getAtomStore().get(urlResolverAtom)[cleansedUrl]).toEqual({
130
+ variant_type: 'pdp',
131
+ ready: true,
132
+ });
133
+ });
134
+
135
+ it('falls back to resolveUrl when precomputedUrlResolving is null (getWidgetConfig path)', async () => {
136
+ await warmUrlResolver({ ...baseParams, precomputedUrlResolving: null });
137
+
138
+ expect(mockResolveUrl).toHaveBeenCalledTimes(1);
139
+ });
85
140
  });
@@ -19,6 +19,15 @@ export interface WarmUrlResolverParams {
19
19
  env?: string;
20
20
  contextSource?: string;
21
21
  featureFlagService?: FeatureFlagService;
22
+ // The url_resolving result /v1/session/bootstrap already produced (unified-config
23
+ // gate ON), paired with the URL it was resolved for. When set AND that URL still
24
+ // matches the current one, the warm-up seeds urlResolverAtom with it and skips
25
+ // its own /v1/url_resolving call — the third leg of the 3→1 consolidation. If a
26
+ // SPA navigation changed the URL since bootstrap ran, the URLs no longer match
27
+ // and the warm-up falls back to resolveUrl (so a stale variant is never cached
28
+ // under the new URL). The atom-seeding above still runs either way, so
29
+ // PageProvider (and any later navigation resolveUrl) sees the same context.
30
+ precomputedUrlResolving?: { url: string; response: UrlResolverResponse } | null;
22
31
  }
23
32
 
24
33
  /**
@@ -43,6 +52,7 @@ export const warmUrlResolver = async ({
43
52
  env,
44
53
  contextSource,
45
54
  featureFlagService,
55
+ precomputedUrlResolving,
46
56
  }: WarmUrlResolverParams): Promise<void> => {
47
57
  try {
48
58
  const store = getAtomStore();
@@ -79,7 +89,14 @@ export const warmUrlResolver = async ({
79
89
  store.set(resolvedBaseConfigVersionAtom, baseVersion);
80
90
  }
81
91
 
82
- const response = (await CommerceApiClient.resolveUrl(cleansedUrl)) as UrlResolverResponse;
92
+ // Bootstrap already resolved a URL in the same round trip as the config —
93
+ // reuse it ONLY when it's still the current URL, so its variant is never
94
+ // cached under a different key after a SPA navigation. Otherwise resolve the
95
+ // current URL ourselves (same as the no-precompute path).
96
+ const response =
97
+ precomputedUrlResolving && precomputedUrlResolving.url === cleansedUrl
98
+ ? precomputedUrlResolving.response
99
+ : ((await CommerceApiClient.resolveUrl(cleansedUrl)) as UrlResolverResponse);
83
100
  store.set(urlResolverAtom, { url: cleansedUrl, response });
84
101
  } catch (err) {
85
102
  // Fail open: PageProvider issues its own resolveUrl call when the cache