@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.
- package/dist/application/models/featureGates.cjs +2 -1
- package/dist/application/models/featureGates.d.cts +2 -1
- package/dist/application/models/featureGates.d.ts +2 -1
- package/dist/application/models/featureGates.js +2 -1
- package/dist/application/utils/widgetTextRequestToApiRequest.cjs +1 -0
- package/dist/application/utils/widgetTextRequestToApiRequest.js +1 -1
- package/dist/atoms/app/variant.d.cts +6 -6
- package/dist/atoms/chat/chatState.d.cts +1 -1
- package/dist/atoms/chat/chatState.d.ts +19 -19
- package/dist/atoms/chat/form.d.cts +2 -2
- package/dist/atoms/chat/form.d.ts +2 -2
- package/dist/atoms/chat/index.d.cts +2 -2
- package/dist/atoms/chat/index.d.ts +3 -3
- package/dist/atoms/chat/lastMessage.d.cts +2 -2
- package/dist/atoms/chat/lastMessage.d.ts +2 -2
- package/dist/atoms/chat/messageQueue.d.cts +7 -7
- package/dist/atoms/chat/messageQueue.d.ts +6 -6
- package/dist/atoms/chat/performanceMetrics.d.cts +6 -6
- package/dist/atoms/chat/performanceMetrics.d.ts +6 -6
- package/dist/atoms/chat/renderedWidgetRefs.d.cts +3 -3
- package/dist/atoms/chat/renderedWidgetRefs.d.ts +2 -2
- package/dist/atoms/chat/replies.d.cts +3 -3
- package/dist/atoms/chat/replies.d.ts +3 -3
- package/dist/atoms/chat/suggestions.d.cts +3 -3
- package/dist/atoms/chat/suggestions.d.ts +2 -2
- package/dist/atoms/envive/enviveConfig.d.cts +13 -13
- package/dist/atoms/envive/enviveConfig.d.ts +14 -14
- package/dist/atoms/globalSearch/globalSearch.d.cts +5 -5
- package/dist/atoms/globalSearch/globalSearch.d.ts +5 -5
- package/dist/atoms/org/customerService.d.cts +6 -6
- package/dist/atoms/org/customerService.d.ts +6 -6
- package/dist/atoms/org/graphqlConfig.d.cts +4 -4
- package/dist/atoms/org/graphqlConfig.d.ts +4 -4
- package/dist/atoms/org/newOrgConfigAtom.d.ts +2 -2
- package/dist/atoms/org/orgAnalyticsConfig.d.ts +4 -4
- package/dist/atoms/search/chatSearch.d.ts +17 -17
- package/dist/atoms/search/searchAPI.d.ts +13 -13
- package/dist/atoms/search/utils.d.ts +1 -1
- package/dist/atoms/widget/chatPreviewLoading.d.ts +2 -2
- package/dist/contexts/systemSettingsContext/systemSettingsContext.d.cts +2 -2
- package/dist/contexts/types.d.cts +1 -1
- package/dist/contexts/types.d.ts +1 -1
- package/dist/hooks/GrabAndScroll/useGrabAndScroll.d.cts +2 -2
- package/dist/hooks/Intersection/useIntersection.cjs +9 -2
- package/dist/hooks/Intersection/useIntersection.d.cts +2 -2
- package/dist/hooks/Intersection/useIntersection.d.ts +2 -2
- package/dist/hooks/Intersection/useIntersection.js +9 -2
- package/dist/hooks/SystemSettingsContext/useSystemSettingsContext.d.cts +2 -2
- package/dist/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.cjs +12 -3
- package/dist/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.d.cts +2 -1
- package/dist/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.d.ts +2 -1
- package/dist/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.js +13 -4
- package/dist/hooks/WidgetLoadDiagnostics/index.cjs +4 -0
- package/dist/hooks/WidgetLoadDiagnostics/index.d.cts +2 -0
- package/dist/hooks/WidgetLoadDiagnostics/index.d.ts +2 -0
- package/dist/hooks/WidgetLoadDiagnostics/index.js +3 -0
- package/dist/hooks/WidgetLoadDiagnostics/useWidgetLoadDiagnostics.cjs +122 -0
- package/dist/hooks/WidgetLoadDiagnostics/useWidgetLoadDiagnostics.d.cts +48 -0
- package/dist/hooks/WidgetLoadDiagnostics/useWidgetLoadDiagnostics.d.ts +48 -0
- package/dist/hooks/WidgetLoadDiagnostics/useWidgetLoadDiagnostics.js +120 -0
- package/dist/services/amplitudeService/eventNames.cjs +2 -1
- package/dist/services/amplitudeService/eventNames.d.cts +2 -1
- package/dist/services/amplitudeService/eventNames.d.ts +2 -1
- package/dist/services/amplitudeService/eventNames.js +2 -1
- package/dist/services/enviveConfigService/enviveConfigService.cjs +49 -3
- package/dist/services/enviveConfigService/enviveConfigService.d.cts +16 -2
- package/dist/services/enviveConfigService/enviveConfigService.d.ts +16 -2
- package/dist/services/enviveConfigService/enviveConfigService.js +49 -3
- package/dist/services/enviveConfigService/fetchBootstrapConfig.cjs +102 -0
- package/dist/services/enviveConfigService/fetchBootstrapConfig.js +102 -0
- package/dist/services/enviveConfigService/fetchGraphQLConfig.cjs +2 -1
- package/dist/services/enviveConfigService/fetchGraphQLConfig.js +2 -2
- package/dist/services/ga4ProjectionService/ga4EventSchema.cjs +3 -2
- package/dist/services/ga4ProjectionService/ga4EventSchema.js +3 -2
- package/dist/services/hardcopyService/hardcopyService.cjs +31 -6
- package/dist/services/hardcopyService/hardcopyService.d.cts +5 -2
- package/dist/services/hardcopyService/hardcopyService.d.ts +5 -2
- package/dist/services/hardcopyService/hardcopyService.js +31 -7
- package/dist/services/hardcopyService/index.cjs +1 -0
- package/dist/services/hardcopyService/index.d.cts +2 -2
- package/dist/services/hardcopyService/index.d.ts +2 -2
- package/dist/services/hardcopyService/index.js +2 -2
- package/dist/services/urlResolverWarmupService/index.cjs +3 -3
- package/dist/services/urlResolverWarmupService/index.d.cts +8 -2
- package/dist/services/urlResolverWarmupService/index.d.ts +8 -2
- package/dist/services/urlResolverWarmupService/index.js +3 -3
- package/package.json +5 -1
- package/src/application/models/featureGates.ts +6 -0
- package/src/hooks/Intersection/useIntersection.ts +11 -0
- package/src/hooks/TrackComponentVisibleEvent/__tests__/useTrackComponentVisibleEvent.test.tsx +64 -0
- package/src/hooks/TrackComponentVisibleEvent/useTrackComponentVisibleEvent.ts +23 -4
- package/src/hooks/WidgetLoadDiagnostics/__tests__/useWidgetLoadDiagnostics.test.ts +161 -0
- package/src/hooks/WidgetLoadDiagnostics/index.ts +5 -0
- package/src/hooks/WidgetLoadDiagnostics/useWidgetLoadDiagnostics.ts +178 -0
- package/src/services/amplitudeService/eventNames.ts +4 -0
- package/src/services/enviveConfigService/__tests__/enviveConfigService.test.ts +226 -0
- package/src/services/enviveConfigService/__tests__/fetchBootstrapConfig.test.ts +271 -0
- package/src/services/enviveConfigService/enviveConfigService.ts +124 -5
- package/src/services/enviveConfigService/fetchBootstrapConfig.ts +276 -0
- package/src/services/enviveConfigService/fetchGraphQLConfig.ts +7 -4
- package/src/services/ga4ProjectionService/ga4EventSchema.ts +5 -0
- package/src/services/hardcopyService/__tests__/hardcopyService.test.ts +73 -3
- package/src/services/hardcopyService/hardcopyService.ts +55 -5
- package/src/services/urlResolverWarmupService/__tests__/urlResolverWarmupService.test.ts +55 -0
- 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
|
|
155
|
-
// getWidgetConfig
|
|
156
|
-
//
|
|
157
|
-
|
|
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 {
|
|
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).
|
|
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).
|
|
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:
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|