@weaverse/hydrogen 5.17.1 → 5.18.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.js +83 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +83 -1
- package/dist/index.mjs.map +1 -1
- package/dist/types/WeaverseHydrogenRoot.d.ts +218 -0
- package/dist/types/WeaverseHydrogenRoot.d.ts.map +1 -0
- package/dist/types/components/index.d.ts +4 -0
- package/dist/types/components/index.d.ts.map +1 -0
- package/dist/types/components/main.d.ts +7 -0
- package/dist/types/components/main.d.ts.map +1 -0
- package/dist/types/hooks/theme-text-context.d.ts +2 -0
- package/dist/types/hooks/theme-text-context.d.ts.map +1 -0
- package/dist/types/hooks/translation-context.d.ts +138 -0
- package/dist/types/hooks/translation-context.d.ts.map +1 -0
- package/dist/types/hooks/use-weaverse-data-context.d.ts +29 -0
- package/dist/types/hooks/use-weaverse-data-context.d.ts.map +1 -0
- package/dist/types/index.d.ts +99 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/seo.d.ts +43 -0
- package/dist/types/seo.d.ts.map +1 -0
- package/dist/types/types.d.ts +826 -0
- package/dist/types/types.d.ts.map +1 -0
- package/dist/types/utils/index.d.ts +44 -0
- package/dist/types/utils/index.d.ts.map +1 -0
- package/dist/types/utils/normalize-page-url.d.ts +16 -0
- package/dist/types/utils/normalize-page-url.d.ts.map +1 -0
- package/dist/types/utils/pick-weaverse-data.d.ts +44 -0
- package/dist/types/utils/pick-weaverse-data.d.ts.map +1 -0
- package/dist/types/utils/pixel.d.ts +16 -0
- package/dist/types/utils/pixel.d.ts.map +1 -0
- package/dist/types/utils/studio-request-info.d.ts +17 -0
- package/dist/types/utils/studio-request-info.d.ts.map +1 -0
- package/dist/types/utils/studio-script-src.d.ts +78 -0
- package/dist/types/utils/studio-script-src.d.ts.map +1 -0
- package/dist/types/utils/sync-reused-instance.d.ts +21 -0
- package/dist/types/utils/sync-reused-instance.d.ts.map +1 -0
- package/dist/types/utils/theme-text-store.d.ts +2 -0
- package/dist/types/utils/theme-text-store.d.ts.map +1 -0
- package/dist/types/utils/translation-store.d.ts +40 -0
- package/dist/types/utils/translation-store.d.ts.map +1 -0
- package/dist/types/utils/use-studio.d.ts +33 -0
- package/dist/types/utils/use-studio.d.ts.map +1 -0
- package/dist/types/utils/use-theme-settings-store.d.ts +39 -0
- package/dist/types/utils/use-theme-settings-store.d.ts.map +1 -0
- package/dist/types/weaverse-client.d.ts +227 -0
- package/dist/types/weaverse-client.d.ts.map +1 -0
- package/package.json +7 -6
- package/src/WeaverseHydrogenRoot.tsx +649 -0
- package/src/components/index.ts +10 -0
- package/src/components/main.tsx +28 -0
- package/src/hooks/theme-text-context.tsx +14 -0
- package/src/hooks/translation-context.tsx +275 -0
- package/src/hooks/use-weaverse-data-context.ts +69 -0
- package/src/index.ts +179 -0
- package/src/seo.ts +125 -0
- package/src/types.ts +1020 -0
- package/src/utils/index.ts +351 -0
- package/src/utils/normalize-page-url.ts +44 -0
- package/src/utils/pick-weaverse-data.ts +89 -0
- package/src/utils/pixel.ts +71 -0
- package/src/utils/studio-request-info.ts +66 -0
- package/src/utils/studio-script-src.ts +162 -0
- package/src/utils/sync-reused-instance.ts +57 -0
- package/src/utils/theme-text-store.ts +1 -0
- package/src/utils/translation-store.ts +65 -0
- package/src/utils/use-studio.ts +168 -0
- package/src/utils/use-theme-settings-store.ts +160 -0
- package/src/weaverse-client.ts +1179 -0
- package/dist/index.d.mts +0 -1260
- 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
|
+
}
|