@weaverse/hydrogen 5.17.1 → 5.19.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.
Files changed (70) hide show
  1. package/dist/index.js +83 -1
  2. package/dist/index.js.map +1 -1
  3. package/dist/index.mjs +83 -1
  4. package/dist/index.mjs.map +1 -1
  5. package/dist/types/WeaverseHydrogenRoot.d.ts +218 -0
  6. package/dist/types/WeaverseHydrogenRoot.d.ts.map +1 -0
  7. package/dist/types/components/index.d.ts +4 -0
  8. package/dist/types/components/index.d.ts.map +1 -0
  9. package/dist/types/components/main.d.ts +7 -0
  10. package/dist/types/components/main.d.ts.map +1 -0
  11. package/dist/types/hooks/theme-text-context.d.ts +2 -0
  12. package/dist/types/hooks/theme-text-context.d.ts.map +1 -0
  13. package/dist/types/hooks/translation-context.d.ts +138 -0
  14. package/dist/types/hooks/translation-context.d.ts.map +1 -0
  15. package/dist/types/hooks/use-weaverse-data-context.d.ts +29 -0
  16. package/dist/types/hooks/use-weaverse-data-context.d.ts.map +1 -0
  17. package/dist/types/index.d.ts +99 -0
  18. package/dist/types/index.d.ts.map +1 -0
  19. package/dist/types/seo.d.ts +43 -0
  20. package/dist/types/seo.d.ts.map +1 -0
  21. package/dist/types/types.d.ts +826 -0
  22. package/dist/types/types.d.ts.map +1 -0
  23. package/dist/types/utils/index.d.ts +44 -0
  24. package/dist/types/utils/index.d.ts.map +1 -0
  25. package/dist/types/utils/normalize-page-url.d.ts +16 -0
  26. package/dist/types/utils/normalize-page-url.d.ts.map +1 -0
  27. package/dist/types/utils/pick-weaverse-data.d.ts +44 -0
  28. package/dist/types/utils/pick-weaverse-data.d.ts.map +1 -0
  29. package/dist/types/utils/pixel.d.ts +16 -0
  30. package/dist/types/utils/pixel.d.ts.map +1 -0
  31. package/dist/types/utils/studio-request-info.d.ts +17 -0
  32. package/dist/types/utils/studio-request-info.d.ts.map +1 -0
  33. package/dist/types/utils/studio-script-src.d.ts +78 -0
  34. package/dist/types/utils/studio-script-src.d.ts.map +1 -0
  35. package/dist/types/utils/sync-reused-instance.d.ts +21 -0
  36. package/dist/types/utils/sync-reused-instance.d.ts.map +1 -0
  37. package/dist/types/utils/theme-text-store.d.ts +2 -0
  38. package/dist/types/utils/theme-text-store.d.ts.map +1 -0
  39. package/dist/types/utils/translation-store.d.ts +40 -0
  40. package/dist/types/utils/translation-store.d.ts.map +1 -0
  41. package/dist/types/utils/use-studio.d.ts +33 -0
  42. package/dist/types/utils/use-studio.d.ts.map +1 -0
  43. package/dist/types/utils/use-theme-settings-store.d.ts +39 -0
  44. package/dist/types/utils/use-theme-settings-store.d.ts.map +1 -0
  45. package/dist/types/weaverse-client.d.ts +227 -0
  46. package/dist/types/weaverse-client.d.ts.map +1 -0
  47. package/package.json +7 -6
  48. package/src/WeaverseHydrogenRoot.tsx +649 -0
  49. package/src/components/index.ts +10 -0
  50. package/src/components/main.tsx +28 -0
  51. package/src/hooks/theme-text-context.tsx +14 -0
  52. package/src/hooks/translation-context.tsx +275 -0
  53. package/src/hooks/use-weaverse-data-context.ts +69 -0
  54. package/src/index.ts +179 -0
  55. package/src/seo.ts +125 -0
  56. package/src/types.ts +1020 -0
  57. package/src/utils/index.ts +351 -0
  58. package/src/utils/normalize-page-url.ts +44 -0
  59. package/src/utils/pick-weaverse-data.ts +89 -0
  60. package/src/utils/pixel.ts +71 -0
  61. package/src/utils/studio-request-info.ts +66 -0
  62. package/src/utils/studio-script-src.ts +162 -0
  63. package/src/utils/sync-reused-instance.ts +57 -0
  64. package/src/utils/theme-text-store.ts +1 -0
  65. package/src/utils/translation-store.ts +65 -0
  66. package/src/utils/use-studio.ts +168 -0
  67. package/src/utils/use-theme-settings-store.ts +160 -0
  68. package/src/weaverse-client.ts +1179 -0
  69. package/dist/index.d.mts +0 -1260
  70. package/dist/index.d.ts +0 -1260
@@ -0,0 +1,351 @@
1
+ import type { HydrogenEnv } from '@shopify/hydrogen'
2
+ import type {
3
+ HydrogenComponent,
4
+ HydrogenComponentSchema,
5
+ HydrogenThemeSchema,
6
+ WeaverseLoaderData,
7
+ WeaverseProjectConfigs,
8
+ WeaverseStudioQueries,
9
+ } from '../types'
10
+ import { isTrustedStudioHost } from './studio-script-src'
11
+
12
+ // Track warned types to avoid spam
13
+ let warnedTypes = new Set<string>()
14
+
15
+ function logOnce(type: string, message: string) {
16
+ if (!warnedTypes.has(type)) {
17
+ console.warn(message)
18
+ warnedTypes.add(type)
19
+ }
20
+ }
21
+
22
+ function deepMergeArrays<T>(target: T[], source: T[]): T[] {
23
+ return [...target, ...source]
24
+ }
25
+
26
+ function mergeInspectorSettings(
27
+ settings: any[],
28
+ inspector: any[],
29
+ type: string
30
+ ): any[] {
31
+ if (!inspector?.length) {
32
+ return settings
33
+ }
34
+ if (!settings?.length) {
35
+ return inspector
36
+ }
37
+
38
+ // Log collision notice
39
+ console.info(
40
+ `Schema "${type}": Both 'settings' and 'inspector' found. Using 'settings' with 'inspector' as fallback.`
41
+ )
42
+
43
+ return deepMergeArrays(settings, inspector)
44
+ }
45
+
46
+ /** Options for extracting query parameters from a request URL. */
47
+ export interface RequestQueryOptions {
48
+ /** Query parameter names to include; all parameters are included when omitted. */
49
+ allowedKeys?: string[]
50
+ }
51
+
52
+ /**
53
+ * Extract and parse query parameters from a request URL.
54
+ * Supports optional allowlist for security-sensitive applications.
55
+ *
56
+ * @param request - The incoming request
57
+ * @param options - Optional configuration
58
+ * @param options.allowedKeys - If provided, only these keys will be extracted
59
+ * @returns Parsed query parameters with type coercion for booleans
60
+ *
61
+ * @example
62
+ * ```typescript
63
+ * // Extract all params
64
+ * const queries = getRequestQueries(request)
65
+ *
66
+ * // Extract only allowed params (security-sensitive)
67
+ * const queries = getRequestQueries(request, {
68
+ * allowedKeys: ['weaverseProjectId', 'weaverseHost']
69
+ * })
70
+ * ```
71
+ */
72
+ export function getRequestQueries<T = Record<string, string | boolean>>(
73
+ request: Request,
74
+ options?: RequestQueryOptions
75
+ ): T {
76
+ let url = new URL(request.url)
77
+ let queries: Record<string, unknown> = {}
78
+
79
+ for (let [key, value] of url.searchParams.entries()) {
80
+ // If allowlist provided, skip non-allowed keys
81
+ if (options?.allowedKeys && !options.allowedKeys.includes(key)) {
82
+ continue
83
+ }
84
+
85
+ // Type-safe conversion for known boolean patterns
86
+ if (value === 'true') {
87
+ queries[key] = true
88
+ } else if (value === 'false') {
89
+ queries[key] = false
90
+ } else {
91
+ queries[key] = value
92
+ }
93
+ }
94
+
95
+ return queries as T
96
+ }
97
+
98
+ /**
99
+ * Resolves project, endpoint, and Studio mode configuration for a request.
100
+ * URL-provided Studio values take precedence over environment defaults.
101
+ */
102
+ export function getWeaverseConfigs(
103
+ request: Request,
104
+ env: HydrogenEnv
105
+ ): WeaverseProjectConfigs {
106
+ let queries = getRequestQueries<WeaverseStudioQueries>(request)
107
+ let {
108
+ WEAVERSE_PROJECT_ID,
109
+ WEAVERSE_API_KEY,
110
+ WEAVERSE_HOST,
111
+ WEAVERSE_PUBLIC_API_BASE,
112
+ PUBLIC_STORE_DOMAIN,
113
+ PUBLIC_STOREFRONT_API_TOKEN,
114
+ } = env || {}
115
+ let envFromProcess = (typeof process !== 'undefined' && process.env) || {}
116
+ let {
117
+ weaverseProjectId,
118
+ weaverseHost,
119
+ isDesignMode,
120
+ isPreviewMode,
121
+ sectionType,
122
+ weaverseVersion,
123
+ weaverseApiKey,
124
+ } = queries
125
+
126
+ let url = new URL(request.url)
127
+ let isRevisionPreview = url.searchParams.has('__revisionId')
128
+ let configuredWeaverseHost = WEAVERSE_HOST || envFromProcess.WEAVERSE_HOST
129
+ // `weaverseHost` arrives from the request URL (Studio drives the preview
130
+ // iframe with it), so it is attacker-controllable; only honor it when it is a
131
+ // trusted Weaverse origin, otherwise fall back to the env/default host. This
132
+ // stops a crafted `?weaverseHost=` from pointing Studio scripts or data reads
133
+ // at an arbitrary origin.
134
+ let trustedUrlHost =
135
+ typeof weaverseHost === 'string' && isTrustedStudioHost(weaverseHost)
136
+ ? weaverseHost
137
+ : ''
138
+ let resolvedWeaverseHost =
139
+ trustedUrlHost || configuredWeaverseHost || 'https://studio.weaverse.io'
140
+ // Public data reads default to the edge proxy, but an explicit custom
141
+ // WEAVERSE_HOST (staging/self-hosted Weaverse) must stay the API base so
142
+ // those deployments don't fetch page/config data from the production proxy.
143
+ let resolvedWeaverseApiBase = trustedUrlHost
144
+ ? trustedUrlHost
145
+ : WEAVERSE_PUBLIC_API_BASE ||
146
+ envFromProcess.WEAVERSE_PUBLIC_API_BASE ||
147
+ (configuredWeaverseHost &&
148
+ configuredWeaverseHost !== 'https://studio.weaverse.io'
149
+ ? configuredWeaverseHost
150
+ : 'https://api.weaverse.io')
151
+
152
+ return {
153
+ projectId:
154
+ weaverseProjectId ||
155
+ WEAVERSE_PROJECT_ID ||
156
+ envFromProcess.WEAVERSE_PROJECT_ID ||
157
+ '',
158
+ weaverseHost: resolvedWeaverseHost,
159
+ weaverseApiBase: resolvedWeaverseApiBase,
160
+ weaversePublicApiBase: resolvedWeaverseApiBase,
161
+ weaverseApiKey:
162
+ weaverseApiKey ||
163
+ WEAVERSE_API_KEY ||
164
+ envFromProcess.WEAVERSE_API_KEY ||
165
+ '',
166
+ weaverseVersion: weaverseVersion || '',
167
+ isDesignMode,
168
+ isPreviewMode,
169
+ isRevisionPreview,
170
+ sectionType: sectionType || '',
171
+ publicEnv: {
172
+ PUBLIC_STORE_DOMAIN: PUBLIC_STORE_DOMAIN || '',
173
+ PUBLIC_STOREFRONT_API_TOKEN: PUBLIC_STOREFRONT_API_TOKEN || '',
174
+ },
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Cache for generateDataFromSchema results using WeakMap.
180
+ * WeakMap allows garbage collection when schema objects are no longer referenced.
181
+ */
182
+ const schemaDefaultsCache = new WeakMap<
183
+ HydrogenComponentSchema | HydrogenThemeSchema,
184
+ Record<string, any>
185
+ >()
186
+
187
+ /**
188
+ * Generate default data from component or theme schema.
189
+ * Results are memoized using WeakMap for performance.
190
+ *
191
+ * @param schema - Component or theme schema
192
+ * @returns Default data object extracted from schema inputs
193
+ */
194
+ export function generateDataFromSchema(
195
+ schema: HydrogenComponentSchema | HydrogenThemeSchema
196
+ ) {
197
+ // Check cache first for performance
198
+ const cached = schemaDefaultsCache.get(schema)
199
+ if (cached) {
200
+ return cached
201
+ }
202
+
203
+ let data: Record<string, any> = {}
204
+
205
+ // Handle migration from inspector to settings
206
+ let inspectorGroups: any[] = []
207
+
208
+ // Type guard to check if it's a component schema
209
+ const isComponentSchema = (
210
+ s: HydrogenComponentSchema | HydrogenThemeSchema
211
+ ): s is HydrogenComponentSchema => 'type' in s && typeof s.type === 'string'
212
+
213
+ if (isComponentSchema(schema)) {
214
+ // Handle HydrogenComponentSchema
215
+ if (schema.settings) {
216
+ inspectorGroups = schema.settings
217
+ }
218
+
219
+ if (schema.inspector) {
220
+ if (schema.settings?.length) {
221
+ // Both exist - merge with settings taking priority
222
+ inspectorGroups = mergeInspectorSettings(
223
+ schema.settings,
224
+ schema.inspector,
225
+ schema.type || 'unknown'
226
+ )
227
+ } else {
228
+ // Only inspector exists - use it but warn about deprecation
229
+ inspectorGroups = schema.inspector
230
+ if (schema.type) {
231
+ logOnce(
232
+ schema.type,
233
+ `⚠️ Schema "${schema.type}": The 'inspector' property is deprecated. Please use 'settings' instead.`
234
+ )
235
+ }
236
+ }
237
+ }
238
+ } else {
239
+ // Handle HydrogenThemeSchema
240
+ if (schema.settings) {
241
+ inspectorGroups = schema.settings
242
+ }
243
+
244
+ if (schema.inspector) {
245
+ if (schema.settings?.length) {
246
+ // Both exist - merge with settings taking priority
247
+ inspectorGroups = mergeInspectorSettings(
248
+ schema.settings,
249
+ schema.inspector,
250
+ 'Theme Schema'
251
+ )
252
+ } else {
253
+ // Only inspector exists - use it but warn about deprecation
254
+ inspectorGroups = schema.inspector
255
+ logOnce(
256
+ 'ThemeSchema',
257
+ `⚠️ Theme Schema: The 'inspector' property is deprecated. Please use 'settings' instead.`
258
+ )
259
+ }
260
+ }
261
+ }
262
+
263
+ if (inspectorGroups?.length) {
264
+ for (let group of inspectorGroups) {
265
+ for (let input of group.inputs) {
266
+ let { name, defaultValue } = input
267
+ if (name && defaultValue !== null && defaultValue !== undefined) {
268
+ data[name] = defaultValue
269
+ }
270
+ }
271
+ }
272
+ }
273
+
274
+ // Cache the result before returning
275
+ schemaDefaultsCache.set(schema, data)
276
+
277
+ return data
278
+ }
279
+
280
+ function recursivelyAddDataItem(
281
+ type: string,
282
+ components: HydrogenComponent<any>[],
283
+ items: any[],
284
+ initData?: object
285
+ ) {
286
+ let component = components.find((c) => c.schema.type === type)
287
+ if (!component) {
288
+ return
289
+ }
290
+ let childIds = []
291
+ let id = crypto.randomUUID()
292
+ let { children, ...data } = component.schema?.presets || {}
293
+ if (children) {
294
+ for (let c of children) {
295
+ let { type, children, ...data } = c
296
+ let childId = recursivelyAddDataItem(type, components, items, data)
297
+ childIds.push({ id: childId } as never)
298
+ }
299
+ }
300
+ items.push({
301
+ data: { ...data, ...initData },
302
+ id,
303
+ type,
304
+ children: childIds,
305
+ })
306
+ return id
307
+ }
308
+
309
+ /** Creates synthetic loader data for previewing one registered component type. */
310
+ export function getPreviewData(
311
+ type: string,
312
+ components: HydrogenComponent[],
313
+ weaverseHost: string
314
+ ) {
315
+ let items = []
316
+ return {
317
+ project: { id: 'x', weaverseShopId: 'shop-id', name: 'Section Preview' },
318
+ configs: {
319
+ projectId: 'x',
320
+ weaverseHost,
321
+ isDesignMode: false,
322
+ isPreviewMode: true,
323
+ requestInfo: {
324
+ i18n: {
325
+ label: 'United States (USD $)',
326
+ language: 'EN',
327
+ country: 'US',
328
+ currency: 'USD',
329
+ pathPrefix: '',
330
+ },
331
+ queries: {},
332
+ pathname: '/',
333
+ search: '',
334
+ },
335
+ },
336
+ page: {
337
+ id: 'weaverse-preview-page',
338
+ name: 'Preview section',
339
+ rootId: '1',
340
+ items: [
341
+ {
342
+ data: {},
343
+ id: '1',
344
+ type: 'main',
345
+ children: [{ id: recursivelyAddDataItem(type, components, items) }],
346
+ },
347
+ ...items,
348
+ ],
349
+ },
350
+ } as unknown as WeaverseLoaderData
351
+ }
@@ -0,0 +1,44 @@
1
+ // Query params that influence Weaverse page resolution on the Builder:
2
+ // revision previews, design-mode flags, and `weaverse*` control params
3
+ // (host, version, project id). Everything else — utm_*, fbclid, gclid,
4
+ // storefront filters — is irrelevant to which page the Builder returns.
5
+ const KEPT_PARAM_RE = /^(?:__revisionId|isDesignMode|weaverse)/
6
+
7
+ /**
8
+ * Normalize the storefront URL sent in the Builder page API request body.
9
+ *
10
+ * The POST body participates in the `withCache` cache key, so a raw
11
+ * `request.url` fragments the cache by every marketing/tracking param:
12
+ * each distinct `?utm_…`/`fbclid` URL is a guaranteed Builder round-trip
13
+ * even when the resolved page is identical — precisely the paid/social
14
+ * traffic that needs the cache most (builder#2450).
15
+ *
16
+ * The Builder reads only the pathname (locale/type/handle fallback) and
17
+ * the params matched above from this URL, so keep exactly those. Params
18
+ * are sorted for a stable cache key regardless of their order in the
19
+ * incoming URL.
20
+ */
21
+ export function normalizePageUrl(url: string): string {
22
+ let parsed: URL
23
+ try {
24
+ parsed = new URL(url)
25
+ } catch {
26
+ // Not an absolute URL (shouldn't happen for request.url) — send as-is.
27
+ return url
28
+ }
29
+ const kept = [...parsed.searchParams].filter(([key]) =>
30
+ KEPT_PARAM_RE.test(key)
31
+ )
32
+ kept.sort(([a], [b]) => a.localeCompare(b))
33
+ const search = new URLSearchParams(kept).toString()
34
+ // React Router single-fetch requests loader data from `/<path>.data`, which
35
+ // resolves to the same Builder page as `/<path>`. Strip the suffix so a
36
+ // client navigation and a document load hash to one edge-cache key (the
37
+ // api.weaverse.io worker keys on this url) instead of two — keeping a page's
38
+ // first client navigation off the cold-MISS path.
39
+ let { pathname } = parsed
40
+ if (pathname.endsWith('.data')) {
41
+ pathname = pathname.slice(0, -'.data'.length)
42
+ }
43
+ return `${parsed.origin}${pathname}${search ? `?${search}` : ''}`
44
+ }
@@ -0,0 +1,89 @@
1
+ import type { UIMatch } from 'react-router'
2
+ import type { WeaverseLoaderData } from '../types'
3
+
4
+ /**
5
+ * Weaverse page data accepted by {@link WeaverseHydrogenRoot}, including
6
+ * deferred React Router loader results and an explicit no-render value.
7
+ */
8
+ export type WeaverseDataValue =
9
+ | WeaverseLoaderData
10
+ | Promise<WeaverseLoaderData>
11
+ | null
12
+
13
+ /**
14
+ * Resolve the `weaverseData` payload for a `<WeaverseHydrogenRoot>` instance.
15
+ *
16
+ * Resolution order (first match wins):
17
+ *
18
+ * 1. **Explicit prop** — the caller passed `data` directly. Used as-is,
19
+ * including when it is a `Promise` (handled upstream via `<Await>`) or
20
+ * an explicit `null` (suppresses rendering).
21
+ * 2. **Own route's loader data** — the loader data for the route that
22
+ * rendered this component, obtained via `useLoaderData()`. This is
23
+ * React Router's route-scoped primitive and is what makes nested
24
+ * layout + child Weaverse pages work: each `<WeaverseContent />`
25
+ * resolves to *its own* route's `weaverseData` regardless of siblings.
26
+ * 3. **Ancestor match fallback** — walk `useMatches()` from leaf to root
27
+ * and return the first ancestor whose `loaderData` exposes a
28
+ * `weaverseData` key. This preserves the long-standing behaviour where
29
+ * a child route with no loader (e.g. an `index.tsx` that omits
30
+ * `weaverseData`) still renders its layout's Weaverse page.
31
+ *
32
+ * The helper is intentionally pure (no hook calls) so it can be unit-tested
33
+ * without a React Router context. Hook calls happen in the component.
34
+ *
35
+ * ## Back-compat contract
36
+ *
37
+ * Themes that pre-date the `data` prop addition pass `dataProp === undefined`,
38
+ * so resolution starts at Tier 2. For the canonical single-instance pattern
39
+ * (one `<WeaverseContent />` inside a route whose loader returns
40
+ * `{ weaverseData }`), Tier 2 returns the same value the old `useMatches()`
41
+ * walk did. For the index-without-loader pattern (leaf route exposes no
42
+ * `weaverseData`, layout does), Tier 3's leaf→root walk produces an
43
+ * identical result to the pre-fix code. The only intentional behaviour
44
+ * change is the issue #451 case: two `<WeaverseContent />` instances on the
45
+ * same URL (layout + child, both with their own `weaverseData`) now each
46
+ * render their own page instead of both rendering the deepest one.
47
+ */
48
+ export function pickWeaverseData(
49
+ dataProp: WeaverseDataValue | undefined,
50
+ ownLoaderData: unknown,
51
+ matches: ReadonlyArray<Pick<UIMatch, 'loaderData'>>
52
+ ): WeaverseDataValue {
53
+ // Tier 1: explicit prop wins (including explicit `null`).
54
+ if (dataProp !== undefined) {
55
+ return dataProp
56
+ }
57
+
58
+ // Tier 2: own route's loader data — the route-scoped primitive.
59
+ if (hasWeaverseData(ownLoaderData)) {
60
+ return ownLoaderData.weaverseData as WeaverseDataValue
61
+ }
62
+
63
+ // Tier 3: ancestor walk (leaf → root) for legacy / index-without-loader cases.
64
+ for (let i = matches.length - 1; i >= 0; i--) {
65
+ const loaderData = matches[i].loaderData
66
+ if (hasWeaverseData(loaderData)) {
67
+ return loaderData.weaverseData as WeaverseDataValue
68
+ }
69
+ }
70
+
71
+ return null
72
+ }
73
+
74
+ function hasWeaverseData(
75
+ value: unknown
76
+ ): value is Record<string, unknown> & { weaverseData: unknown } {
77
+ // The `in` check alone returns true for `{ weaverseData: undefined }`,
78
+ // which would let Tier 2 short-circuit and return `undefined` — bypassing
79
+ // the Tier 3 ancestor walk that would otherwise resolve the missing data.
80
+ // Require the value to be present (not `undefined`).
81
+ if (
82
+ typeof value !== 'object' ||
83
+ value === null ||
84
+ !('weaverseData' in (value as Record<string, unknown>))
85
+ ) {
86
+ return false
87
+ }
88
+ return (value as Record<string, unknown>).weaverseData !== undefined
89
+ }
@@ -0,0 +1,71 @@
1
+ // One pixel per page per navigation. A few facts shape the dedup:
2
+ //
3
+ // 1. Co-located instances: nested compositions mount multiple Weaverse
4
+ // instances on one URL; each page is counted at most once.
5
+ // 2. The pixel FIRE effect runs once per instance mount (see `usePixel`), so
6
+ // an instance never re-fires on its own — a navigation is a fresh mount.
7
+ // 3. `location.key` is stable per history entry. A back/forward POP restores
8
+ // the entry's ORIGINAL key, and an in-place remount on the same entry
9
+ // (e.g. the `data={null}` suppress/restore path in WeaverseHydrogenRoot)
10
+ // keeps that same key. A REAL navigation is therefore a key CHANGE.
11
+ //
12
+ // `firedPageIds` is the set of pages already counted for the current
13
+ // navigation. It is forgotten in two ways:
14
+ // - On a key TRANSITION reported by ANY mounted instance via
15
+ // `observeNavigation` (driven by a per-navigation effect). A persistent
16
+ // layout-level instance keeps observing across a detour through a
17
+ // non-Weaverse route (cart, search, account), so a Back to the original
18
+ // entry is recognised as a new navigation and the page re-counts — while
19
+ // an in-place suppress/restore (no key change) does NOT re-count.
20
+ // - When the LAST instance unmounts (`registerPixelInstance`), covering the
21
+ // detour case where no instance survives to observe the key round-trip
22
+ // (a page with no persistent layout going to /cart and back).
23
+ //
24
+ // Known tradeoff: React StrictMode's dev-only mount→cleanup→mount cycle
25
+ // resets state and double-fires in development — parity with the pre-dedupe
26
+ // behavior; production semantics are unaffected.
27
+ let currentNavigationKey: string | null = null
28
+ let firedPageIds = new Set<string>()
29
+ let mountedInstances = 0
30
+
31
+ /**
32
+ * Record the navigation key seen by a mounted instance. A change of key is a
33
+ * real navigation and forgets the previous navigation's fired pages; an
34
+ * unchanged key (an in-place remount / suppress-restore on the same history
35
+ * entry) leaves them intact, so the page is not double-counted.
36
+ */
37
+ export function observeNavigation(navigationKey: string): void {
38
+ if (navigationKey !== currentNavigationKey) {
39
+ currentNavigationKey = navigationKey
40
+ firedPageIds.clear()
41
+ }
42
+ }
43
+
44
+ export function shouldFirePixel(
45
+ navigationKey: string,
46
+ pageId: string
47
+ ): boolean {
48
+ observeNavigation(navigationKey)
49
+ if (firedPageIds.has(pageId)) {
50
+ return false
51
+ }
52
+ firedPageIds.add(pageId)
53
+ return true
54
+ }
55
+
56
+ /**
57
+ * Track a mounted Weaverse instance; returns the unmount cleanup. When the
58
+ * last instance unmounts, the navigation state is forgotten so a Back that
59
+ * restores the same history entry counts again even if no instance survived
60
+ * to observe the detour.
61
+ */
62
+ export function registerPixelInstance(): () => void {
63
+ mountedInstances += 1
64
+ return () => {
65
+ mountedInstances -= 1
66
+ if (mountedInstances === 0) {
67
+ currentNavigationKey = null
68
+ firedPageIds.clear()
69
+ }
70
+ }
71
+ }
@@ -0,0 +1,66 @@
1
+ import type { WeaverseHydrogenParams } from '../types'
2
+
3
+ interface BrowserLocationLike {
4
+ pathname: string
5
+ search: string
6
+ }
7
+
8
+ function getBrowserLocation(): BrowserLocationLike | undefined {
9
+ if (typeof window === 'undefined') {
10
+ return
11
+ }
12
+ return window.location
13
+ }
14
+
15
+ function getQueriesFromSearch(
16
+ search: string
17
+ ): Record<string, string | boolean> {
18
+ let queries: Record<string, string | boolean> = {}
19
+ let params = new URLSearchParams(search)
20
+ for (let [key, value] of params.entries()) {
21
+ if (value === 'true') {
22
+ queries[key] = true
23
+ } else if (value === 'false') {
24
+ queries[key] = false
25
+ } else {
26
+ queries[key] = value
27
+ }
28
+ }
29
+ return queries
30
+ }
31
+
32
+ /**
33
+ * React Router v7 single-fetch revalidation can render client data returned
34
+ * from a `*.data` request while the browser is still on the canonical URL.
35
+ * Instance identity must follow the browser URL, not the transient data
36
+ * endpoint, otherwise the SDK creates a fresh Weaverse instance mid-session:
37
+ * in design mode that drops the existing Studio bridge/interactions, and in
38
+ * the live theme it resets every section's runtime state after any
39
+ * revalidation (cart mutations, locale switches, `revalidate()`). See #451.
40
+ */
41
+ export function normalizeRequestInfo(
42
+ params: WeaverseHydrogenParams,
43
+ browserLocation: BrowserLocationLike | undefined = getBrowserLocation()
44
+ ): WeaverseHydrogenParams {
45
+ if (!browserLocation) {
46
+ return params
47
+ }
48
+
49
+ let { pathname, search } = browserLocation
50
+ if (
51
+ params.requestInfo.pathname === pathname &&
52
+ params.requestInfo.search === search
53
+ ) {
54
+ return params
55
+ }
56
+
57
+ return {
58
+ ...params,
59
+ requestInfo: {
60
+ ...params.requestInfo,
61
+ pathname,
62
+ queries: getQueriesFromSearch(search),
63
+ search,
64
+ },
65
+ }
66
+ }