@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,162 @@
1
+ /** Rendering mode and host details used to select a Studio bridge script. */
2
+ export interface StudioMode {
3
+ /** Whether the storefront is running inside the visual editor. */
4
+ isDesignMode?: boolean
5
+ /** Whether a component preview is being rendered. */
6
+ isPreviewMode?: boolean
7
+ /** Whether a saved page revision is being previewed. */
8
+ isRevisionPreview?: boolean
9
+ /** Trusted origin serving Studio bridge assets. */
10
+ weaverseHost: string
11
+ /** Studio asset version appended to the script URL. */
12
+ weaverseVersion: string
13
+ }
14
+
15
+ const DEFAULT_WEAVERSE_HOST = 'https://studio.weaverse.io'
16
+
17
+ /**
18
+ * Weaverse-controlled apex domains the Studio bridge may be served from
19
+ * (matched on the host itself and any subdomain).
20
+ */
21
+ const TRUSTED_STUDIO_DOMAINS = ['weaverse.io', 'weaverse.dev']
22
+
23
+ function isLoopbackHostname(hostname: string): boolean {
24
+ return (
25
+ hostname === 'localhost' ||
26
+ hostname === '127.0.0.1' ||
27
+ hostname.endsWith('.localhost')
28
+ )
29
+ }
30
+
31
+ export interface TrustedStudioHostOptions {
32
+ /**
33
+ * Also trust loopback hosts (`localhost` / `127.0.0.1` / `*.localhost`) over
34
+ * http or https.
35
+ *
36
+ * Only safe when the **storefront itself** is loopback (genuine local-builder
37
+ * dev). A production https page can still load a loopback script — loopback is
38
+ * a "potentially trustworthy" origin and is not blocked as mixed content — so
39
+ * honoring a query-supplied loopback host on a public storefront would let a
40
+ * crafted link run whatever a visitor's local service serves inside the
41
+ * storefront origin. MUST stay off on the server, where it is an SSRF vector
42
+ * for page/theme fetches.
43
+ */
44
+ allowLoopback?: boolean
45
+ }
46
+
47
+ /**
48
+ * Whether `host` is an origin we trust to serve the Studio bridge script (or,
49
+ * server-side, to resolve page/theme data).
50
+ *
51
+ * The bridge executes inside the storefront document and the host can drive
52
+ * server-side fetches, so a host taken from an attacker-controllable source —
53
+ * e.g. a crafted `?weaverseHost=` query — is both an XSS and an SSRF vector.
54
+ * Callers MUST reject untrusted hosts before acting on them. Trusts
55
+ * `weaverse.io`/`weaverse.dev` and their subdomains over https; loopback is
56
+ * opt-in (see {@link TrustedStudioHostOptions.allowLoopback}).
57
+ */
58
+ export function isTrustedStudioHost(
59
+ host: string,
60
+ options?: TrustedStudioHostOptions
61
+ ): boolean {
62
+ let parsed: URL
63
+ try {
64
+ parsed = new URL(host)
65
+ } catch {
66
+ return false
67
+ }
68
+ let { hostname, protocol } = parsed
69
+ // Loopback (opt-in) is the only place http is allowed — a local builder
70
+ // serves the bridge over http and there is no cleartext-downgrade risk to a
71
+ // private address. Every other trusted host must be https so server-side
72
+ // page/theme fetches are never downgraded to cleartext.
73
+ if (options?.allowLoopback && isLoopbackHostname(hostname)) {
74
+ return protocol === 'http:' || protocol === 'https:'
75
+ }
76
+ if (protocol !== 'https:') {
77
+ return false
78
+ }
79
+ return TRUSTED_STUDIO_DOMAINS.some(
80
+ (domain) => hostname === domain || hostname.endsWith(`.${domain}`)
81
+ )
82
+ }
83
+
84
+ /**
85
+ * Build the Studio bridge script URL for the current mode, or `null` when the
86
+ * storefront is not being previewed inside Weaverse Studio.
87
+ *
88
+ * This is the single gate that keeps the bridge off production storefronts:
89
+ * a normal visitor's URL carries none of the design/preview markers, so the
90
+ * result is `null` and no script is ever requested.
91
+ *
92
+ * Shared by the page-scoped `useStudio` (which also binds the page for
93
+ * editing) and the root-level `useStudioConnect` (which only needs the script
94
+ * to load so Studio's `checkWeaversePage()` handshake can answer on routes
95
+ * with no Weaverse content).
96
+ */
97
+ export function getStudioScriptSrc(mode: StudioMode): string | null {
98
+ let {
99
+ isDesignMode,
100
+ isPreviewMode,
101
+ isRevisionPreview,
102
+ weaverseHost,
103
+ weaverseVersion,
104
+ } = mode
105
+ let base = `${weaverseHost}/static/studio/hydrogen`
106
+ let version = `?v=${weaverseVersion}`
107
+ if (isRevisionPreview || isPreviewMode) {
108
+ return `${base}/preview.js${version}`
109
+ }
110
+ if (isDesignMode) {
111
+ return `${base}/index.js${version}`
112
+ }
113
+ return null
114
+ }
115
+
116
+ /**
117
+ * Resolve the Studio bridge script URL from a URL query string — the markers
118
+ * Studio attaches when it drives the preview iframe (`isDesignMode`,
119
+ * `isPreviewMode`, `__revisionId`, `weaverseHost`, `weaverseVersion`) — or
120
+ * `null` outside Studio.
121
+ *
122
+ * `storefrontHostname` is the document's own hostname: a loopback Studio host
123
+ * is only honored when the storefront itself is loopback (genuine local-builder
124
+ * dev), so a crafted public link can't load a script from a visitor's loopback
125
+ * service into the storefront origin.
126
+ *
127
+ * The `weaverseHost` query is attacker-controllable, so a host that is not a
128
+ * {@link isTrustedStudioHost trusted origin} resolves to `null` (no bridge) —
129
+ * it is never silently replaced with production Studio, which would otherwise
130
+ * inject the wrong bridge for self-hosted/staging deployments. Such deployments
131
+ * (a non-`weaverse.io` `WEAVERSE_HOST`) therefore don't load the root bridge on
132
+ * content-less routes; the page-scoped {@link getStudioScriptSrc} via
133
+ * `useStudio` still uses the configured host on content routes.
134
+ *
135
+ * Pure (no DOM) so the root-level connect's production gate stays unit-testable
136
+ * and needs no loader data.
137
+ */
138
+ export function resolveStudioScriptSrc(
139
+ search: string,
140
+ storefrontHostname = ''
141
+ ): string | null {
142
+ let params = new URLSearchParams(search)
143
+ // Match the server's getRequestQueries(), which keeps the LAST value of a
144
+ // duplicated key. Studio appends its own control params, so a pre-existing
145
+ // (stale/untrusted) `weaverseHost` earlier in the URL must not win here, or
146
+ // the root bridge would be skipped while the loader uses the later host.
147
+ let last = (key: string) => params.getAll(key).at(-1)
148
+ let host = last('weaverseHost')
149
+ if (host) {
150
+ let allowLoopback = isLoopbackHostname(storefrontHostname)
151
+ if (!isTrustedStudioHost(host, { allowLoopback })) {
152
+ return null
153
+ }
154
+ }
155
+ return getStudioScriptSrc({
156
+ isDesignMode: last('isDesignMode') === 'true',
157
+ isPreviewMode: last('isPreviewMode') === 'true',
158
+ isRevisionPreview: params.has('__revisionId'),
159
+ weaverseHost: host || DEFAULT_WEAVERSE_HOST,
160
+ weaverseVersion: last('weaverseVersion') || '',
161
+ })
162
+ }
@@ -0,0 +1,57 @@
1
+ import type { WeaverseHydrogenParams } from '../types'
2
+ import type { WeaverseHydrogen } from '../WeaverseHydrogenRoot'
3
+
4
+ /**
5
+ * Apply a fresh loader payload to a reused Weaverse instance.
6
+ *
7
+ * With requestInfo normalized to the browser URL, same-URL revalidations
8
+ * (cart mutations, `useRevalidator()`, locale-neutral re-fetches) reuse the
9
+ * cached `window.__weaverses[pageId]` instance instead of constructing a new
10
+ * one — which preserves section state, but would silently drop the fresh
11
+ * `data`/`dataContext` the loaders just returned. Sync them here.
12
+ *
13
+ * Design mode is excluded by the caller: Studio owns the instance data there
14
+ * (unsaved drafts must not be clobbered) and applies updates via
15
+ * `refreshStudio` instead.
16
+ *
17
+ * Change detection uses JSON comparison — loader payloads are
18
+ * wire-serialized, so key order is stable between runs; a false mismatch
19
+ * only costs one extra re-render.
20
+ */
21
+ export function syncReusedInstance(
22
+ weaverse: WeaverseHydrogen,
23
+ params: WeaverseHydrogenParams
24
+ ) {
25
+ weaverse.requestInfo = params.requestInfo
26
+ weaverse.internal = params.internal
27
+
28
+ let dataChanged =
29
+ JSON.stringify(weaverse.data) !== JSON.stringify(params.data)
30
+ let contextChanged =
31
+ JSON.stringify(weaverse.dataContext ?? null) !==
32
+ JSON.stringify(params.dataContext ?? null)
33
+
34
+ if (contextChanged) {
35
+ // Assign before notifying any item store: components resolve data
36
+ // connectors against `weaverse.dataContext` at render time, so the
37
+ // fresh context must be in place when re-renders flush.
38
+ weaverse.dataContext = params.dataContext ?? null
39
+ }
40
+ if (dataChanged) {
41
+ // State-preserving: existing item stores receive `setData` (and emit),
42
+ // missing ones are created — no full instance teardown.
43
+ weaverse.setProjectData(params.data)
44
+ } else if (contextChanged) {
45
+ // Context-only change: memoized item components subscribe to their item
46
+ // store snapshot, and snapshot identity follows the `_store` reference —
47
+ // a bare `triggerUpdate()` would notify with an identical snapshot and
48
+ // `useSyncExternalStore` would skip the re-render. `setData({})` swaps
49
+ // the store reference so connectors re-resolve against the new context.
50
+ for (const { id } of params.data?.items || []) {
51
+ weaverse.itemInstances.get(id)?.setData({})
52
+ }
53
+ }
54
+ if (dataChanged || contextChanged) {
55
+ weaverse.triggerUpdate()
56
+ }
57
+ }
@@ -0,0 +1 @@
1
+ export { ThemeTextStore, TranslationStore } from './translation-store'
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Minimal mutable store for design-mode static text overrides.
3
+ *
4
+ * useSyncExternalStore-compatible. Passed into TranslationProvider which
5
+ * subscribes internally and merges overrides into merchantOverrides.
6
+ * The studio bridge calls updateOverrides() via weaverse.internal.translationStore
7
+ * (also exposed as the deprecated weaverse.internal.themeTextStore alias).
8
+ *
9
+ * Shape: flat Record<string, string> — dot-notation keys map to values
10
+ * (e.g. "cart.title" → "Shopping Cart").
11
+ */
12
+ export class TranslationStore {
13
+ /** Current flat translation overrides keyed by dot-path. */
14
+ overrides: Record<string, string> = {}
15
+ private listeners: Set<() => void> = new Set()
16
+
17
+ /** Replaces all overrides and notifies subscribers. */
18
+ setOverrides = (overrides: Record<string, string>) => {
19
+ this.overrides = { ...overrides }
20
+ this.emit()
21
+ }
22
+
23
+ /** Merges changed overrides and notifies subscribers. */
24
+ updateOverrides = (newOverrides: Record<string, string>) => {
25
+ this.overrides = { ...this.overrides, ...newOverrides }
26
+ this.emit()
27
+ }
28
+
29
+ /** Returns the current client snapshot for `useSyncExternalStore`. */
30
+ getSnapshot = () => this.overrides
31
+
32
+ /** Returns the current server snapshot for `useSyncExternalStore`. */
33
+ getServerSnapshot = () => this.overrides
34
+
35
+ /** Subscribes to override changes and returns an unsubscribe callback. */
36
+ subscribe = (callback: () => void) => {
37
+ this.listeners.add(callback)
38
+ return () => {
39
+ this.listeners.delete(callback)
40
+ }
41
+ }
42
+
43
+ private emit = () => {
44
+ for (const listener of Array.from(this.listeners)) {
45
+ try {
46
+ listener()
47
+ } catch (error) {
48
+ console.error('TranslationStore: Listener error', error)
49
+ }
50
+ }
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Legacy constructor alias retained for existing theme text integrations.
56
+ * @deprecated Use {@link TranslationStore} instead. This alias remains available
57
+ * for backward compatibility.
58
+ */
59
+ export const ThemeTextStore = TranslationStore
60
+ /**
61
+ * Legacy type alias retained for existing theme text integrations.
62
+ * @deprecated Use {@link TranslationStore} instead. This alias remains available
63
+ * for backward compatibility.
64
+ */
65
+ export type ThemeTextStore = TranslationStore
@@ -0,0 +1,168 @@
1
+ import { loadScript } from '@weaverse/react'
2
+ import { useEffect } from 'react'
3
+ import {
4
+ useLocation,
5
+ useNavigate,
6
+ useNavigation,
7
+ useRevalidator,
8
+ } from 'react-router'
9
+ import { useTranslation } from '../hooks/translation-context'
10
+ import type { WeaverseHydrogen } from '../index'
11
+ import { hasWeaverseStudio } from '../types'
12
+ import {
13
+ observeNavigation,
14
+ registerPixelInstance,
15
+ shouldFirePixel,
16
+ } from './pixel'
17
+ import { getStudioScriptSrc, resolveStudioScriptSrc } from './studio-script-src'
18
+ import { useThemeSettingsStore } from './use-theme-settings-store'
19
+
20
+ const STUDIO_READY_POLL_MS = 50
21
+ const STUDIO_READY_TIMEOUT_MS = 5000
22
+
23
+ /**
24
+ * Resolve once `window.weaverseStudio` is available, or `null` on timeout.
25
+ * The studio script sets the global when it *executes*; `loadScript` can
26
+ * resolve earlier for tags it did not create (e.g. SSR-injected markup),
27
+ * so never assume readiness right after the script promise settles.
28
+ */
29
+ function waitForStudio(): Promise<Window['weaverseStudio'] | null> {
30
+ return new Promise((resolve) => {
31
+ let startedAt = Date.now()
32
+ function check() {
33
+ if (hasWeaverseStudio(window)) {
34
+ return resolve(window.weaverseStudio)
35
+ }
36
+ if (Date.now() - startedAt >= STUDIO_READY_TIMEOUT_MS) {
37
+ console.warn('[Weaverse] Studio script did not initialize in time')
38
+ return resolve(null)
39
+ }
40
+ setTimeout(check, STUDIO_READY_POLL_MS)
41
+ }
42
+ check()
43
+ })
44
+ }
45
+
46
+ /**
47
+ * Load the Studio bridge script on every design/preview render so Studio's
48
+ * `checkWeaversePage()` handshake is answered instead of timing out — crucially
49
+ * on routes where the page-scoped {@link useStudio} never mounts: 404s, error
50
+ * boundaries, and non-Weaverse routes. The bridge registers its RPC endpoint
51
+ * and `window.weaverseStudio` the moment the script executes, independent of any
52
+ * page binding. Without this, a content-less route never loads the script and
53
+ * Studio reports a false "Connection lost" instead of "this page has no Weaverse
54
+ * content set up".
55
+ *
56
+ * `loadScript` is idempotent, so on content routes this coexists with the
57
+ * page-scoped {@link useStudio} (same src) — that hook still binds the page via
58
+ * `init`. Gating this on whether a route "has Weaverse data" is deliberately
59
+ * avoided: route matches can't tell whether the page-scoped bridge actually
60
+ * mounts (an `ErrorBoundary` replacing a data route, or data passed via the
61
+ * `data` prop), and suppressing the script there would reintroduce the timeout
62
+ * this fixes. Loading the script is harmless; it only registers the responder.
63
+ *
64
+ * Known limitation: on a route whose `weaverseData` is a deferred Promise, the
65
+ * script can load before the page streams in, so `checkWeaversePage()` may
66
+ * briefly observe `NOT_WEAVERSE_PAGE`. The complete fix is a "loading" state in
67
+ * `checkWeaversePage` so the handshake retries rather than latching — that lives
68
+ * in the Studio script, not here. Tracked in Weaverse/builder#2515.
69
+ *
70
+ * Mounted via {@link withWeaverse}, which must be the root route's `Layout`
71
+ * export so the bridge renders even when a route's `ErrorBoundary` replaces the
72
+ * page. Outside Studio (no design/preview params) it is a no-op.
73
+ */
74
+ export function useStudioConnect() {
75
+ let { search } = useLocation()
76
+ useEffect(() => {
77
+ // Loopback Studio hosts are only honored when the storefront itself is
78
+ // loopback (see resolveStudioScriptSrc), so pass the document's hostname.
79
+ let hostname = typeof window === 'undefined' ? '' : window.location.hostname
80
+ let src = resolveStudioScriptSrc(search, hostname)
81
+ if (src) {
82
+ loadScript(src).catch(console.error)
83
+ }
84
+ }, [search])
85
+ }
86
+
87
+ export function useStudio(weaverse: WeaverseHydrogen) {
88
+ let { revalidate } = useRevalidator()
89
+ let navigation = useNavigation()
90
+ let { pathname, search } = useLocation()
91
+ let navigate = useNavigate()
92
+ let themeSettingsStore = useThemeSettingsStore()
93
+ let { translationStore, themeTextStore, merchantOverrides } = useTranslation()
94
+ let {
95
+ isDesignMode,
96
+ weaverseHost,
97
+ weaverseVersion,
98
+ isPreviewMode,
99
+ isRevisionPreview,
100
+ } = weaverse
101
+
102
+ // biome-ignore lint/correctness/useExhaustiveDependencies: only revalidate on pathname/search changes
103
+ useEffect(() => {
104
+ if (navigation.state !== 'idle') {
105
+ return
106
+ }
107
+ let src = getStudioScriptSrc({
108
+ isDesignMode,
109
+ isPreviewMode,
110
+ isRevisionPreview,
111
+ weaverseHost,
112
+ weaverseVersion,
113
+ })
114
+ if (!src) {
115
+ return
116
+ }
117
+ if (isRevisionPreview || isPreviewMode) {
118
+ loadScript(src).catch(console.error)
119
+ } else {
120
+ weaverse.internal = {
121
+ ...weaverse.internal,
122
+ navigate,
123
+ revalidate,
124
+ merchantOverrides,
125
+ themeSettingsStore,
126
+ translationStore,
127
+ // Deprecated alias for existing Studio bridge integrations.
128
+ themeTextStore,
129
+ }
130
+ loadScript(src)
131
+ .then(waitForStudio)
132
+ .then((studio) => studio?.init(weaverse))
133
+ .catch(console.error)
134
+ }
135
+ }, [pathname, search, navigation.state])
136
+ usePixel(weaverse)
137
+ }
138
+
139
+ export function usePixel(context: WeaverseHydrogen) {
140
+ let { projectId, pageId, isDesignMode, weaverseHost } = context
141
+ let { key: navigationKey } = useLocation()
142
+ // Observe every navigation so a persistent layout-level instance forgets
143
+ // the previous navigation's fired pages on a real key change — but NOT on
144
+ // an in-place remount that keeps the same history entry (e.g. the
145
+ // `data={null}` suppress/restore path), which must not double-count.
146
+ useEffect(() => {
147
+ if (!(projectId && pageId) || isDesignMode) {
148
+ return
149
+ }
150
+ observeNavigation(navigationKey)
151
+ }, [navigationKey, projectId, pageId, isDesignMode])
152
+
153
+ // biome-ignore lint/correctness/useExhaustiveDependencies: fire once on mount
154
+ useEffect(() => {
155
+ if (!(projectId && pageId) || isDesignMode) {
156
+ return
157
+ }
158
+ // Register BEFORE deciding to fire: the navigation state must live at
159
+ // least as long as some Weaverse instance is mounted (see pixel.ts).
160
+ let unregister = registerPixelInstance()
161
+ if (shouldFirePixel(navigationKey, pageId)) {
162
+ let img = new Image()
163
+ img.onload = () => img.remove()
164
+ img.src = `${weaverseHost}/api/public/px?projectId=${projectId}&pageId=${pageId}`
165
+ }
166
+ return unregister
167
+ }, [])
168
+ }
@@ -0,0 +1,160 @@
1
+ import { isBrowser } from '@weaverse/react'
2
+ import { createContext, useContext, useEffect, useRef } from 'react'
3
+ import { useRouteLoaderData } from 'react-router'
4
+ import type {
5
+ HydrogenThemeSchema,
6
+ HydrogenThemeSettings,
7
+ PublicEnv,
8
+ } from '../types'
9
+
10
+ /** Root-loader theme data used to initialize a {@link ThemeSettingsStore}. */
11
+ export interface WeaverseThemeData {
12
+ /** Storefront-safe environment values, when available. */
13
+ publicEnv?: PublicEnv
14
+ /** Theme schema exposed to Studio, when available. */
15
+ schema?: HydrogenThemeSchema
16
+ /** Published theme setting values. */
17
+ theme: HydrogenThemeSettings
18
+ }
19
+
20
+ /** Mutable, subscribable store for theme settings received from the root loader. */
21
+ export class ThemeSettingsStore {
22
+ /** Current merged theme setting values. */
23
+ settings: HydrogenThemeSettings = {}
24
+ /** Theme schema exposed to Studio, when available. */
25
+ schema?: HydrogenThemeSchema
26
+ /** Storefront-safe environment values, when available. */
27
+ publicEnv?: PublicEnv
28
+ private listeners: Set<() => void> = new Set()
29
+ private hasWarnedListenerCount = false
30
+ private isDestroyed = false
31
+
32
+ /** Creates a theme settings store from root-loader theme data. */
33
+ constructor(data: WeaverseThemeData) {
34
+ const { theme, schema, publicEnv } = data || {}
35
+ this.settings = { ...theme }
36
+ this.schema = schema
37
+ this.publicEnv = publicEnv
38
+ if (isBrowser) {
39
+ window.__weaverseThemeSettingsStore = this
40
+ }
41
+ }
42
+
43
+ /** Merges setting changes into the current snapshot and notifies subscribers. */
44
+ updateThemeSettings = (newSettings: HydrogenThemeSettings) => {
45
+ if (this.isDestroyed) {
46
+ console.warn('ThemeSettingsStore: Cannot update destroyed store')
47
+ return
48
+ }
49
+
50
+ this.settings = {
51
+ ...this.settings,
52
+ ...newSettings,
53
+ }
54
+ this.emit()
55
+ }
56
+
57
+ /** Returns the current client snapshot for `useSyncExternalStore`. */
58
+ getSnapshot = () => this.settings
59
+
60
+ /** Returns the current server snapshot for `useSyncExternalStore`. */
61
+ getServerSnapshot = () => this.settings
62
+
63
+ /** Subscribes to setting changes and returns an unsubscribe callback. */
64
+ subscribe = (callback: () => void) => {
65
+ if (this.isDestroyed) {
66
+ console.warn('ThemeSettingsStore: Cannot subscribe to destroyed store')
67
+ return () => {}
68
+ }
69
+
70
+ this.listeners.add(callback)
71
+
72
+ if (!this.hasWarnedListenerCount && this.listeners.size > 500) {
73
+ this.hasWarnedListenerCount = true
74
+ console.warn(
75
+ `ThemeSettingsStore: ${this.listeners.size} listeners detected. ` +
76
+ 'This may indicate a performance issue. Consider using fewer useThemeSettings() calls.'
77
+ )
78
+ }
79
+
80
+ return () => {
81
+ this.listeners.delete(callback)
82
+ }
83
+ }
84
+
85
+ private emit = () => {
86
+ if (this.isDestroyed) {
87
+ return
88
+ }
89
+
90
+ const currentListeners = Array.from(this.listeners)
91
+
92
+ for (const listener of currentListeners) {
93
+ try {
94
+ listener()
95
+ } catch (error) {
96
+ console.error('ThemeSettingsStore: Listener error', error)
97
+ }
98
+ }
99
+ }
100
+
101
+ /** Clears subscribers and detaches the browser-global store reference. */
102
+ destroy = () => {
103
+ if (this.isDestroyed) {
104
+ return
105
+ }
106
+
107
+ this.listeners.clear()
108
+ this.isDestroyed = true
109
+
110
+ if (isBrowser && window.__weaverseThemeSettingsStore === this) {
111
+ window.__weaverseThemeSettingsStore = undefined
112
+ }
113
+ }
114
+ }
115
+
116
+ export const ThemeSettingsStoreContext =
117
+ createContext<ThemeSettingsStore | null>(null)
118
+ ThemeSettingsStoreContext.displayName = 'ThemeSettingsStoreContext'
119
+
120
+ export function useCreateThemeSettingsStore(): ThemeSettingsStore {
121
+ const data = useRouteLoaderData('root') as {
122
+ weaverseTheme: WeaverseThemeData
123
+ }
124
+ const storeRef = useRef<ThemeSettingsStore | null>(null)
125
+
126
+ if (!storeRef.current) {
127
+ storeRef.current = new ThemeSettingsStore(data?.weaverseTheme)
128
+ }
129
+
130
+ const theme = data?.weaverseTheme?.theme
131
+ useEffect(() => {
132
+ if (storeRef.current && theme) {
133
+ storeRef.current.updateThemeSettings(theme)
134
+ }
135
+ }, [theme])
136
+
137
+ return storeRef.current
138
+ }
139
+
140
+ export function useThemeSettingsStore(): ThemeSettingsStore {
141
+ const fromContext = useContext(ThemeSettingsStoreContext)
142
+ const data = useRouteLoaderData('root') as {
143
+ weaverseTheme: WeaverseThemeData
144
+ }
145
+ const fallbackRef = useRef<ThemeSettingsStore | null>(null)
146
+
147
+ if (!(fallbackRef.current || fromContext)) {
148
+ fallbackRef.current = new ThemeSettingsStore(data?.weaverseTheme)
149
+ }
150
+
151
+ const store = fromContext ?? fallbackRef.current!
152
+ const theme = data?.weaverseTheme?.theme
153
+ useEffect(() => {
154
+ if (!fromContext && theme) {
155
+ store.updateThemeSettings(theme)
156
+ }
157
+ }, [fromContext, store, theme])
158
+
159
+ return store
160
+ }