@pack/hydrogen 3.3.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,15 +1,51 @@
1
1
  import { PackClient } from '@pack/client';
2
2
  import { CacheCustom } from '@shopify/hydrogen';
3
3
  import { SessionStorage, Session, ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
4
- import * as react from 'react';
5
- import react__default, { Dispatch, SetStateAction, PropsWithChildren } from 'react';
4
+ import * as React from 'react';
5
+ import React__default, { Dispatch, SetStateAction, PropsWithChildren } from 'react';
6
+
7
+ type ConsentState = "granted" | "denied" | "unknown";
8
+ declare const CONSENT_PURPOSES: readonly ["analytics", "marketing", "preferences", "saleOfData"];
9
+ type ConsentPurpose = (typeof CONSENT_PURPOSES)[number];
10
+ /** The visitor's consent for each purpose, as resolved for one request. */
11
+ type PackConsent = Record<ConsentPurpose, ConsentState>;
12
+ /** Whether each purpose may be used, after the unknown-consent policy. */
13
+ type PackConsentAllowed = Record<ConsentPurpose, boolean>;
14
+ /** A consent update: `true`/`false` or an explicit state, per purpose. */
15
+ type ConsentInput = Partial<Record<ConsentPurpose, boolean | ConsentState | null | undefined>>;
16
+ /**
17
+ * Returns the visitor's consent from the theme's own consent tool (OneTrust,
18
+ * Cookiebot, ...). Return `undefined` for a purpose the tool has no answer for;
19
+ * the `__pack_user_consent` cookie is consulted next.
20
+ *
21
+ * Called on every request before anything renders, with no timeout: read the
22
+ * tool's own cookie from `request` rather than calling an API.
23
+ */
24
+ type GetConsent = (request: Request) => ConsentInput | undefined | Promise<ConsentInput | undefined>;
25
+ /**
26
+ * What an `unknown` purpose is treated as. `"granted"` keeps today's behaviour
27
+ * for visitors who have not answered a banner.
28
+ */
29
+ type UnknownConsentPolicy = "granted" | "denied" | ((request: Request) => "granted" | "denied");
30
+ interface ConsentOptions {
31
+ getConsent?: GetConsent;
32
+ unknownConsent?: UnknownConsentPolicy;
33
+ }
6
34
 
7
35
  declare class PackSession {
8
36
  #private;
9
- readonly id: string;
10
37
  readonly secret: string;
11
- constructor(id: string, secret: string, sessionStorage: SessionStorage, session: Session);
12
- static init(request: Request, secrets: string[]): Promise<PackSession>;
38
+ constructor(id: string, secret: string, sessionStorage: SessionStorage, session: Session, consent: PackConsent, unknownAs: "granted" | "denied");
39
+ static init(request: Request, secrets: string[], options?: ConsentOptions): Promise<PackSession>;
40
+ /**
41
+ * The visitor's session id. Persistent only with analytics consent; otherwise
42
+ * a fresh id on every request that is never written to the cookie.
43
+ */
44
+ get id(): string;
45
+ get consent(): PackConsent;
46
+ get consentAllowed(): PackConsentAllowed;
47
+ /** Replace the consent for the rest of this request, e.g. after `/pack/consent`. */
48
+ setConsent(consent: PackConsent): void;
13
49
  has(key: string): boolean;
14
50
  get(key: string): any;
15
51
  destroy(): Promise<string>;
@@ -17,6 +53,27 @@ declare class PackSession {
17
53
  commit(): Promise<string>;
18
54
  }
19
55
 
56
+ /**
57
+ * What the theme tells us about who is signed in.
58
+ *
59
+ * `isLoggedIn` alone is enough for `auth_status`, and it is the cheap half: a
60
+ * session check rather than a Customer Account API query. Supply
61
+ * `shopifyCustomerId` only where the id itself is needed.
62
+ */
63
+ interface CustomerContext {
64
+ isLoggedIn: boolean;
65
+ shopifyCustomerId?: string;
66
+ }
67
+ /**
68
+ * Asked only when an audience on this request could depend on who is signed
69
+ * in, at most once per request. Make it a local session read (Hydrogen's
70
+ * `customerAccount.isLoggedIn()`), not a Customer Account API query: it sits
71
+ * on the SSR critical path, bounded by `customerContextTimeoutMs`.
72
+ */
73
+ type GetCustomerContext = (context: {
74
+ request: Request;
75
+ }) => Promise<CustomerContext | undefined> | CustomerContext | undefined;
76
+
20
77
  interface TestInput {
21
78
  testId?: string;
22
79
  testHandle?: string;
@@ -34,16 +91,86 @@ interface Test$1 {
34
91
  impression?: {
35
92
  sectionIds?: string[];
36
93
  };
94
+ /**
95
+ * Set on a personalization, so the exposure ledger records it compactly. It
96
+ * is never stored in the session; see `getTestInfo`.
97
+ */
98
+ personalization?: true;
37
99
  }
38
100
 
101
+ /**
102
+ * The sticky half of a bucketing decision — the only thing that has to survive
103
+ * between requests.
104
+ *
105
+ * `impressionTrigger` and `impression.sectionIds` are deliberately NOT stored.
106
+ * Every consumer of those two fields reads them off loader data produced from
107
+ * the freshly-fetched test rules in that same request (see
108
+ * `getImpressionSectionIdsForVariant`), so a stored copy is never read back — it
109
+ * is pure cookie weight, and it goes stale the moment the merchant edits the
110
+ * variant's sections.
111
+ */
112
+ interface TestAssignment {
113
+ id: string;
114
+ handle: string;
115
+ testVariant: {
116
+ id: string;
117
+ handle: string;
118
+ };
119
+ assignedAt: number;
120
+ }
121
+ type TestAssignments = Record<string, TestAssignment>;
39
122
  declare class PackTestSession {
40
123
  #private;
41
124
  readonly id: string;
42
- constructor(id: string, sessionStorage: SessionStorage, session: Session);
125
+ constructor(id: string, sessionStorage: SessionStorage, session: Session, isNewId?: boolean);
43
126
  static init(request: Request, secrets: string[]): Promise<PackTestSession>;
127
+ /**
128
+ * Keep a freshly generated id once a traffic draw has been made against it.
129
+ *
130
+ * A new id does not dirty the session on its own, so a visitor whose draw
131
+ * missed every test was never written, got a new id on the next request, and
132
+ * drew again until one hit. Called only after a draw rather than on creation,
133
+ * so visitors matching no test still get no cookie.
134
+ */
135
+ persistId(): void;
136
+ /**
137
+ * Every assignment on the session, keyed by test id.
138
+ *
139
+ * Normalizes and migrates on read, persisting only when the stored shape
140
+ * actually differs — a plain read of an already-clean session must not mark
141
+ * the session dirty, or `hasChanges()` would rewrite the cookie on every
142
+ * response.
143
+ */
144
+ getAssignments(): TestAssignments;
145
+ getAssignmentIds(): string[];
146
+ getAssignment(testId: string): TestAssignment | undefined;
147
+ hasAssignment(testId: string): boolean;
148
+ /**
149
+ * The session's stable primary — the oldest assignment, tie-broken by test id.
150
+ * Backs the single-test compatibility surface (`getTestData` -> `pack.abTest`),
151
+ * so it must not change as further tests are assigned during a visit.
152
+ */
153
+ getPrimaryAssignment(): TestAssignment | undefined;
154
+ /**
155
+ * Upsert one test's assignment. Returns false when the cap blocks a new
156
+ * assignment; updating a test already on the session always succeeds.
157
+ */
158
+ setAssignment(assignment: Omit<TestAssignment, "assignedAt"> & {
159
+ assignedAt?: number;
160
+ }): boolean;
161
+ clearAssignment(testId: string): void;
162
+ clearAllAssignments(): void;
163
+ /**
164
+ * @deprecated Reads only the primary assignment. Use `getAssignments()` —
165
+ * a session can hold one assignment per concurrently eligible test.
166
+ */
44
167
  getTestData(): Test$1 | undefined;
45
- getExpireAt(): string | undefined;
168
+ /**
169
+ * @deprecated Upserts a single test. Use `setAssignment()` /
170
+ * `clearAllAssignments()`.
171
+ */
46
172
  setTestData(testData: Test$1 | undefined): void;
173
+ getExpireAt(): string | undefined;
47
174
  setExpireAt(expireAt: string | undefined): void;
48
175
  clearTestData(): void;
49
176
  hasTestData(): boolean;
@@ -85,6 +212,28 @@ interface CreatePackClientOptions extends EnvironmentOptions {
85
212
  i18n?: I18nOptions;
86
213
  /** Default theme data to use when no token is provided */
87
214
  defaultThemeData?: DefaultThemeData;
215
+ /**
216
+ * Who is signed in, for audiences that ask (`auth_status`). Called at most
217
+ * once per request, and only when an audience actually needs it.
218
+ *
219
+ * Return `{ isLoggedIn }` from a session check; add `shopifyCustomerId` only
220
+ * where the id itself is needed, since that costs a Customer Account API
221
+ * query. Omitting this option, throwing, or timing out all leave the answer
222
+ * UNKNOWN, and audiences that need it are refused rather than being served as
223
+ * if the visitor were a guest.
224
+ */
225
+ getCustomerContext?: GetCustomerContext;
226
+ /**
227
+ * Bound on `getCustomerContext`, in milliseconds. Defaults to 300 — enough for
228
+ * a session read or a healthy token refresh. Raise it only if expiry warnings
229
+ * show up; the hook sits on the SSR critical path, paid on any render where an
230
+ * audience could depend on who is signed in.
231
+ *
232
+ * If the hook throws or times out, the answer is unknown and those audiences
233
+ * are refused for that request: a visitor already seeing a signed-in
234
+ * personalization gets default content on that one render.
235
+ */
236
+ customerContextTimeoutMs?: number;
88
237
  /**
89
238
  * Initial request to extract query parameters from.
90
239
  * If not provided, it will be captured from the first handleRequest call.
@@ -107,7 +256,16 @@ interface QueryError {
107
256
  interface QueryResponse<T> {
108
257
  data: T | null;
109
258
  error: QueryError | null;
110
- packTestInfo?: Test$1;
259
+ /**
260
+ * Tests newly assigned on this request, each owing one exposure.
261
+ *
262
+ * A list once a visitor can hold several tests. Deliberately the same key as
263
+ * the single-test era rather than a new one: themes forward this value to
264
+ * `PackTestRoute` without inspecting it, so widening it in place means an
265
+ * existing theme reports every exposure with no change. A lone test is still
266
+ * emitted unwrapped so anything that does read it keeps working.
267
+ */
268
+ packTestInfo?: Test$1 | Test$1[];
111
269
  }
112
270
  interface PackCustomizerMeta {
113
271
  environment?: string;
@@ -129,6 +287,8 @@ interface PackCustomizerMeta {
129
287
  }
130
288
  interface Pack {
131
289
  abTest: Test$1 | null | undefined;
290
+ /** Every test this visitor is bucketed into, including ones not serving here. */
291
+ abTests: Test$1[];
132
292
  /**
133
293
  * @deprecated The method should not be used
134
294
  */
@@ -136,6 +296,7 @@ interface Pack {
136
296
  storeId: string;
137
297
  sessionId: string;
138
298
  abTest: Test$1 | null | undefined;
299
+ abTests: Test$1[];
139
300
  isPreviewModeEnabled: boolean;
140
301
  customizerMeta: any;
141
302
  };
@@ -143,9 +304,16 @@ interface Pack {
143
304
  packStoreId: string;
144
305
  packSessionId: string;
145
306
  packAbTest: Test$1 | null | undefined;
307
+ packAbTests: Test$1[];
146
308
  packIsPreviewMode: boolean;
147
309
  packCustomizerMeta: PackCustomizerMeta | null;
310
+ packConsent: PackConsent;
311
+ packConsentAllowed: PackConsentAllowed;
148
312
  };
313
+ /** The visitor's consent per purpose, as resolved for this request. */
314
+ consent: PackConsent;
315
+ /** Whether each purpose may be used, after the unknown-consent policy. */
316
+ consentAllowed: PackConsentAllowed;
149
317
  handleRequest(request: Request): Promise<(response: Response) => void>;
150
318
  isPreviewModeEnabled: () => boolean;
151
319
  isValidEditToken: PackClient["isValidEditToken"];
@@ -162,16 +330,26 @@ declare function handleRequest(pack: Pack, request: Request, handleRequest: (req
162
330
 
163
331
  type UsePackCookiesOptions = {
164
332
  /**
165
- * If set to `false`, Shopify cookies will be removed.
166
- * If set to `true`, Shopify unique user token cookie will have cookie expiry of 1 year.
167
- * Defaults to false.
333
+ * The visitor's analytics consent. When omitted, nothing is recorded.
168
334
  **/
169
335
  hasUserConsent?: boolean;
170
336
  /**
171
- * The domain scope of the cookie. Defaults to empty string.
337
+ * @deprecated Ignored. `__pack_user_consent` is now set by the server for
338
+ * the storefront's own host.
172
339
  **/
173
340
  domain?: string;
174
341
  };
342
+ /**
343
+ * Record the visitor's analytics consent in `__pack_user_consent`.
344
+ *
345
+ * @deprecated Use `setPackConsent()` from `@pack/react`, called when the
346
+ * visitor answers, which also covers marketing, preferences and sale of data.
347
+ * Themes using Shopify's privacy banner need neither: `PackProvider` forwards
348
+ * the banner's answer on its own.
349
+ *
350
+ * Posts only when `hasUserConsent` is given and differs from the recorded
351
+ * answer, so a stale value cannot overwrite the banner's on every page load.
352
+ */
175
353
  declare function usePackCookies(options?: UsePackCookiesOptions): void;
176
354
 
177
355
  /**
@@ -188,7 +366,7 @@ type PackTestImpressionSelector = string | string[] | ((packTestInfo: Test$1) =>
188
366
  type PackTestRouteProps = {
189
367
  impressionSelector?: PackTestImpressionSelector;
190
368
  };
191
- declare const PackTestRoute: ({ impressionSelector, }?: PackTestRouteProps) => null;
369
+ declare const PackTestRoute: ({ impressionSelector, }?: PackTestRouteProps) => React__default.JSX.Element | null;
192
370
 
193
371
  interface Test {
194
372
  id: string;
@@ -212,35 +390,89 @@ type PackTestContextValue = {
212
390
  setPendingExposureQueue?: Dispatch<SetStateAction<Map<any, any>>>;
213
391
  exposedExperiments?: Set<unknown>;
214
392
  };
215
- declare const PackTestContext: react.Context<PackTestContextValue>;
393
+ declare const PackTestContext: React.Context<PackTestContextValue>;
216
394
  declare const usePackTestContext: () => PackTestContextValue;
217
395
 
218
396
  interface PackContentProps {
219
397
  testExposureCallback?: (test: Test) => void;
398
+ /**
399
+ * Whether exposures may be reported. Defaults to the analytics consent the
400
+ * server resolved (`packConsentAllowed` from `getPackContextData()`), and to
401
+ * `false` when the root loader does not expose it.
402
+ */
220
403
  hasUserConsent?: boolean;
221
404
  }
222
- declare function PackTestProvider({ children, testExposureCallback, hasUserConsent, }: PropsWithChildren<PackContentProps>): react__default.JSX.Element;
405
+ declare function PackTestProvider({ children, testExposureCallback, hasUserConsent: hasUserConsentProp, }: PropsWithChildren<PackContentProps>): React__default.JSX.Element;
223
406
 
224
- declare function useAbTest(): Test$1 | null;
407
+ /**
408
+ * One test the visitor is bucketed into: given a handle, that specific test;
409
+ * argless, the oldest assignment.
410
+ *
411
+ * Argless is **not** necessarily the test supplying content on this page. While a
412
+ * visitor could hold only one test it was, so branching on `useAbTest()?.handle`
413
+ * to choose variant markup was safe; a visitor can now hold several. Pass a
414
+ * handle to name the test you mean, or use `useAbTests` for all of them.
415
+ *
416
+ * A handle matches on assignment, not on targeting: a test scoped to
417
+ * `/products/` is still returned on other pages, where its CMS content is not
418
+ * being served.
419
+ */
420
+ declare function useAbTest(testHandle?: string): Test$1 | null;
421
+ /**
422
+ * Every test the visitor is bucketed into, including ones whose targeting does
423
+ * not match the current page.
424
+ *
425
+ * Returns [] on a theme that has not adopted the plural context field, rather
426
+ * than throwing — `useAbTest` remains the compatibility surface.
427
+ */
428
+ declare function useAbTests(): Test$1[];
225
429
  declare function useAbTestSessionId(): string;
226
- declare function useAbTestId(): string | undefined;
227
- declare function useAbTestHandle(): string | undefined;
228
- declare function useAbTestVariantId(): string | undefined;
229
- declare function useAbTestVariantHandle(): string | undefined;
430
+ /**
431
+ * Each of these takes the same optional handle as `useAbTest`. Argless they read
432
+ * the visitor's oldest assignment, which is only the test you mean if they hold
433
+ * exactly one.
434
+ *
435
+ * `useAbTestVariantHandle` is the one that bites: new tests default to variants
436
+ * named `control` and `variant-b`, so unless renamed an argless read can return
437
+ * `"variant-b"` from one test while the test being branched on has the visitor in
438
+ * `control` — variant markup shown to a control-arm visitor. Pass the handle of
439
+ * the test being branched on.
440
+ */
441
+ declare function useAbTestId(testHandle?: string): string | undefined;
442
+ declare function useAbTestHandle(testHandle?: string): string | undefined;
443
+ declare function useAbTestVariantId(testHandle?: string): string | undefined;
444
+ declare function useAbTestVariantHandle(testHandle?: string): string | undefined;
230
445
 
231
446
  /**
232
447
  * The identity signals audience conditions evaluate against. Built once per SSR
233
448
  * request and passed to the resolver; every condition reads from this bundle
234
449
  * rather than re-parsing the request.
235
450
  *
236
- * Two population levels:
237
- * - request-only (handleRequest / A/B): everything except customer identity;
238
- * - request + customer (root loader / Personalization): also `shopifyCustomerId`.
239
- * All fields are optional; customer-keyed conditions simply don't match at the
240
- * request-only level.
451
+ * Every field is optional, and absence is not "no": a condition needing a
452
+ * signal that is missing is REFUSED by `unmetRequirements`, not evaluated
453
+ * against a default. Several conditions' documented fallback for a missing
454
+ * signal is to match — `auth_status: "anonymous"` matches precisely when no
455
+ * customer is known — so evaluating without one would serve to everybody.
456
+ *
457
+ * This used to describe two population levels, request-only for A/B in
458
+ * handleRequest and request-plus-customer for Personalization in the root
459
+ * loader. That split was never implemented: `handleRequest` performs no
460
+ * assignment, and both experiment types resolve in `getTestInfo` on the first
461
+ * CMS query of a request. The customer signal is therefore not a property of
462
+ * WHERE evaluation happens; it is supplied to it by the theme through
463
+ * `getCustomerContext`.
241
464
  */
242
465
  interface IdentityBundle {
243
466
  shopifyCustomerId?: string;
467
+ /**
468
+ * Tri-state: `undefined` means the theme did not tell us, and
469
+ * `unmetRequirements` refuses on it. Defaulting to `false` would make
470
+ * `auth_status: "anonymous"` match signed-in visitors too.
471
+ *
472
+ * Separate from `shopifyCustomerId`, which costs a Customer Account API query;
473
+ * `auth_status` only needs the boolean.
474
+ */
475
+ isLoggedIn?: boolean;
244
476
  ga4ClientId?: string;
245
477
  amplitudeDeviceId?: string;
246
478
  amplitudeUserId?: string;
@@ -262,8 +494,14 @@ interface IdentityBundle {
262
494
  referrer?: string;
263
495
  }
264
496
  interface BuildIdentityBundleOptions {
265
- /** Shopify customer id, when resolved (loader-level / Personalization). */
497
+ /** Shopify customer id, when the theme supplied one. */
266
498
  shopifyCustomerId?: string;
499
+ /**
500
+ * Analytics consent as resolved for the Pack session (`getConsent`, the
501
+ * cookie, and the unknown-consent policy). Without it, only the cookie is
502
+ * read and an unanswered visitor counts as consented.
503
+ */
504
+ hasConsent?: boolean;
267
505
  }
268
506
  /**
269
507
  * Builds an IdentityBundle from an SSR request (headers + cookies) and, when
@@ -309,4 +547,4 @@ interface AudienceResolution {
309
547
  */
310
548
  declare function resolveRulesTree(rules: AudienceRulesTree | null | undefined, identity: IdentityBundle): Promise<AudienceResolution>;
311
549
 
312
- export { type AudienceResolution, type AudienceRuleNode, type AudienceRulesTree, type BuildIdentityBundleOptions, type ConditionEvaluator, type IdentityBundle, type MatchedCondition, type Pack, PackSession, PackTestContext, PackTestProvider, PackTestRoute, PackTestSession, type RuleConditionNode, type RuleGroupNode, type RuleGroupOperator, buildIdentityBundle, createPackClient, getConditionEvaluator, handleRequest, isConditionRegistered, action as previewModeAction, loader as previewModeLoader, registerConditionEvaluator, resolveRulesTree, useAbTest, useAbTestHandle, useAbTestId, useAbTestSessionId, useAbTestVariantHandle, useAbTestVariantId, usePackCookies, usePackTestContext };
550
+ export { type AudienceResolution, type AudienceRuleNode, type AudienceRulesTree, type BuildIdentityBundleOptions, type ConditionEvaluator, type CustomerContext, type GetConsent, type GetCustomerContext, type IdentityBundle, type MatchedCondition, type Pack, type PackConsent, PackSession, PackTestContext, PackTestProvider, PackTestRoute, PackTestSession, type RuleConditionNode, type RuleGroupNode, type RuleGroupOperator, type TestAssignment, type TestAssignments, buildIdentityBundle, createPackClient, getConditionEvaluator, handleRequest, isConditionRegistered, action as previewModeAction, loader as previewModeLoader, registerConditionEvaluator, resolveRulesTree, useAbTest, useAbTestHandle, useAbTestId, useAbTestSessionId, useAbTestVariantHandle, useAbTestVariantId, useAbTests, usePackCookies, usePackTestContext };