@faststore/core 4.4.0-dev.3 → 4.4.0-dev.5

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 (31) hide show
  1. package/.turbo/turbo-generate.log +3 -3
  2. package/.turbo/turbo-test.log +49 -44
  3. package/CHANGELOG.md +12 -0
  4. package/README.md +15 -0
  5. package/package.json +6 -6
  6. package/src/components/account/Drawer/OrganizationDrawer/OrganizationDrawerBody.tsx +1 -1
  7. package/src/components/account/Menu/Menu.tsx +2 -1
  8. package/src/components/account/orders/ListOrders/FilterSlider/FilterSlider.tsx +5 -1
  9. package/src/components/account/orders/ListOrders/ListOrders.tsx +8 -3
  10. package/src/components/account/orders/ListOrders/ListOrdersTable/ListOrdersTable.tsx +6 -3
  11. package/src/components/account/orders/ListOrders/SelectedTags/SelectedTags.tsx +2 -2
  12. package/src/components/account/orders/OrderDetails/OrderDetailsHeader.tsx +6 -1
  13. package/src/components/account/utils/useFormatPrice.ts +3 -2
  14. package/src/components/ui/Link/Link.tsx +6 -0
  15. package/src/experimental/index.ts +3 -0
  16. package/src/experimental/myAccountServerSideProps.ts +6 -2
  17. package/src/pages/pvt/account/404.tsx +5 -1
  18. package/src/pages/pvt/account/[...unknown].tsx +6 -2
  19. package/src/pages/pvt/account/index.tsx +5 -4
  20. package/src/pages/pvt/account/profile.tsx +2 -1
  21. package/src/pages/pvt/account/user-details.tsx +4 -3
  22. package/src/proxy.ts +36 -0
  23. package/src/sdk/session/index.ts +9 -6
  24. package/src/sdk/session/initialSession.ts +31 -46
  25. package/src/utils/localization/localizeRedirectDestination.ts +66 -0
  26. package/src/utils/localization/withLocaleValidation.ts +0 -6
  27. package/test/proxy.localization.test.ts +122 -0
  28. package/test/sdk/session/reconcileSessionLocale.browser.test.ts +199 -0
  29. package/test/sdk/session/reconcileSessionLocale.disabled.browser.test.ts +38 -0
  30. package/test/utils/localization/localizeRedirectDestination.test.ts +176 -0
  31. package/test/sdk/session/installLocaleCorrector.browser.test.ts +0 -208
package/src/proxy.ts CHANGED
@@ -48,6 +48,29 @@ const validLocales = new Set(
48
48
  Object.keys(storeConfig.localization?.locales || {})
49
49
  )
50
50
 
51
+ /**
52
+ * Resolves the locale Next.js already assigned to this request (path prefix or
53
+ * domain routing). For _next/data routes the locale is embedded in the path.
54
+ */
55
+ function getActiveLocaleFromRequest(request: NextRequest): string | undefined {
56
+ const { pathname } = request.nextUrl
57
+ const dataMatch = pathname.match(DATA_ROUTE_RE)
58
+
59
+ if (dataMatch) {
60
+ const localeFromData = dataMatch[1]
61
+ if (validLocales.has(localeFromData)) {
62
+ return localeFromData
63
+ }
64
+ }
65
+
66
+ const { locale } = request.nextUrl
67
+ if (locale && validLocales.has(locale)) {
68
+ return locale
69
+ }
70
+
71
+ return undefined
72
+ }
73
+
51
74
  /**
52
75
  * Rewrites the request to the subdomain locale.
53
76
  * Without this, getStaticProps receives the wrong locale on subdomain domains
@@ -61,6 +84,19 @@ function rewriteSubdomainRequest(
61
84
  request: NextRequest,
62
85
  locale: string
63
86
  ): NextResponse | null {
87
+ const activeLocale = getActiveLocaleFromRequest(request)
88
+
89
+ // Root bindings share a hostname with path-prefixed locales (e.g. /pt-BR).
90
+ // When Next already resolved a different locale, skip injecting the subdomain
91
+ // locale to avoid double-prefix rewrites like /pt-BR/en-US/...
92
+ if (
93
+ activeLocale &&
94
+ validLocales.has(activeLocale) &&
95
+ activeLocale !== locale
96
+ ) {
97
+ return null
98
+ }
99
+
64
100
  const { pathname, search } = request.nextUrl
65
101
 
66
102
  const dataMatch = pathname.match(DATA_ROUTE_RE)
@@ -19,7 +19,7 @@ import { cartStore } from '../cart'
19
19
  import { request } from '../graphql/request'
20
20
  import { createValidationStore, useStore } from '../useStore'
21
21
  import { getPostalCode } from '../userLocation/index'
22
- import { getInitialSession, installLocaleCorrector } from './initialSession'
22
+ import { getInitialSession, reconcileSessionLocale } from './initialSession'
23
23
  import { RELOAD_AFTER_LOGOUT_KEY, SESSION_READY_KEY } from './storageKeys'
24
24
 
25
25
  const isReloadAfterLogoutPending = (): boolean => {
@@ -195,11 +195,14 @@ const [validationStore, onValidate, hasValidatedStore] =
195
195
  createValidationStore(validateSession)
196
196
 
197
197
  const urlAwareInitialSession = getInitialSession()
198
- const defaultStore = createSessionStore(urlAwareInitialSession, onValidate)
199
-
200
- if (storeConfig.localization?.enabled && typeof window !== 'undefined') {
201
- installLocaleCorrector(defaultStore, urlAwareInitialSession)
202
- }
198
+ const defaultStore = createSessionStore(
199
+ urlAwareInitialSession,
200
+ onValidate,
201
+ 'fs::session',
202
+ storeConfig.localization?.enabled
203
+ ? { reconcile: reconcileSessionLocale }
204
+ : undefined
205
+ )
203
206
 
204
207
  export const sessionStore = {
205
208
  ...defaultStore,
@@ -1,5 +1,4 @@
1
- import type { Session, Store } from '@faststore/sdk'
2
- import deepEqual from 'fast-deep-equal'
1
+ import type { Session } from '@faststore/sdk'
3
2
 
4
3
  import storeConfig from '../../../discovery.config'
5
4
  import { getSettings } from '../localization/settings'
@@ -31,10 +30,10 @@ function safeParseChannel(
31
30
  * useLocalizationConfig detect the URL locale and correct the session —
32
31
  * causing a second, redundant pair of validateSession + validateCart calls.
33
32
  *
34
- * By seeding the initial value with the URL locale we ensure:
35
- * 1. readInitial() already contains the correct locale (fixes first-visit).
36
- * 2. The correction subscriber below can fix IDB payloads that carry an
37
- * outdated locale without an extra round-trip.
33
+ * By seeding the initial value with the URL locale we ensure readInitial()
34
+ * already contains the correct locale (fixes first-visit). IDB payloads that
35
+ * carry an outdated locale are corrected by `reconcileSessionLocale` (wired as
36
+ * the `persisted` reconcile seam in `createSessionStore`).
38
37
  */
39
38
  export function getInitialSession(
40
39
  defaults: Session = storeConfig.session
@@ -60,50 +59,36 @@ export function getInitialSession(
60
59
  }
61
60
 
62
61
  /**
63
- * Installs a one-shot subscriber that corrects the locale/currency/channel
64
- * when the persisted middleware hydrates a session from IDB whose values
65
- * differ from the URL-derived initial session.
62
+ * Reconciles a session payload read from IDB (on hydration and on the
63
+ * `focus`/`visibilitychange` cross-tab sync) with the URL-derived localization
64
+ * settings, forcing `locale`/`currency`/`salesChannel` to follow the URL while
65
+ * preserving every other field from the persisted payload.
66
66
  *
67
- * Why this works:
68
- * - It must be subscribed AFTER `optimistic`, so it fires within the same
69
- * synchronous `store.set()` call that enqueues `validateSession(stale)`.
70
- * - It then calls `store.set(corrected)`, re-entering the base store's set().
71
- * The base store invokes `cancelations.forEach(cancel)` which flips the
72
- * `cancel` flag of the just-enqueued optimistic handler — preventing the
73
- * stale `validateSession` from ever hitting the network — and broadcasts
74
- * again, enqueueing a new optimistic handler with the corrected value.
67
+ * This is the SDK `persisted` reconcile seam (see `CreateStoreOptions`): the
68
+ * URL is the single source of truth for localization, so neither a stale IDB
69
+ * value nor a `validateSession` round-trip (which receives no locale hint in
70
+ * `search` on path-based bindings) can flip the locale back to the default.
75
71
  *
76
- * The `corrected` flag guarantees the subscriber runs at most once: subsequent
77
- * locale changes (e.g. from `LocalizationSelector`) must not be intercepted.
78
- *
79
- * @returns Unsubscribe function (mainly useful in tests).
72
+ * When localization is disabled or there is no `window` (SSR), the payload is
73
+ * returned untouched.
80
74
  */
81
- export function installLocaleCorrector(
82
- store: Store<Session>,
83
- initialSession: Session
84
- ): () => void {
85
- let corrected = false
86
-
87
- return store.subscribe((session: Session) => {
88
- if (corrected) return
89
- corrected = true
90
-
91
- if (
92
- session.locale === initialSession.locale &&
93
- deepEqual(session.currency, initialSession.currency) &&
94
- session.channel === initialSession.channel
95
- ) {
96
- return
97
- }
75
+ export function reconcileSessionLocale(fromIDB: Session): Session {
76
+ if (!storeConfig.localization?.enabled || typeof window === 'undefined') {
77
+ return fromIDB
78
+ }
98
79
 
99
- const channel = safeParseChannel(session.channel)
100
- channel.salesChannel = safeParseChannel(initialSession.channel).salesChannel
80
+ try {
81
+ const settings = getSettings()
82
+ const channel = safeParseChannel(fromIDB.channel)
83
+ channel.salesChannel = settings.salesChannel
101
84
 
102
- store.set({
103
- ...session,
104
- locale: initialSession.locale,
105
- currency: initialSession.currency,
85
+ return {
86
+ ...fromIDB,
87
+ locale: settings.locale,
88
+ currency: settings.currency,
106
89
  channel: JSON.stringify(channel),
107
- })
108
- })
90
+ }
91
+ } catch {
92
+ return fromIDB
93
+ }
109
94
  }
@@ -0,0 +1,66 @@
1
+ import storeConfig from 'discovery.config'
2
+ import { addCustomPathPrefix, extractCustomPathPrefix } from './bindingPaths'
3
+
4
+ type RedirectLocaleContext = {
5
+ locale?: string
6
+ defaultLocale?: string
7
+ /**
8
+ * Present in `getServerSideProps` (absent in `getStaticProps`). Its `url`
9
+ * keeps the original, pre-proxy-rewrite path — including custom binding
10
+ * prefixes like `/europe/it` — which `resolvedUrl` does not.
11
+ */
12
+ req?: { url?: string }
13
+ }
14
+
15
+ /**
16
+ * Localizes an internal redirect `destination` for `getServerSideProps` /
17
+ * `getStaticProps`, mirroring the client-side `useLink.resolveLink` logic.
18
+ *
19
+ * Next.js (Pages Router) does NOT add the active locale to a
20
+ * `redirect.destination`, so without this the redirect drops to the default
21
+ * binding. Resolution order (same as `resolveLink`):
22
+ *
23
+ * 1. If the request is on a custom binding path (e.g. `/europe/it`, `/emea-eur`),
24
+ * preserve that prefix via `addCustomPathPrefix` — the `proxy.ts` rewrite maps
25
+ * the prefixed URL back to the right locale, keeping the user on the vanity path.
26
+ * 2. Otherwise, if on a non-default Next locale, prepend `/${locale}`.
27
+ * 3. Otherwise (default locale / subdomain binding), return as-is.
28
+ *
29
+ * The custom-path branch requires `context.req.url`; in `getStaticProps` (no
30
+ * request) only the locale branch applies.
31
+ */
32
+ export function localizeRedirectDestination(
33
+ destination: string,
34
+ context: RedirectLocaleContext
35
+ ): string {
36
+ if (!storeConfig.localization?.enabled) {
37
+ return destination
38
+ }
39
+
40
+ // Only internal, absolute paths are localized (skip external URLs and
41
+ // protocol-relative `//host` destinations).
42
+ if (!destination.startsWith('/') || destination.startsWith('//')) {
43
+ return destination
44
+ }
45
+
46
+ const requestUrl = context.req?.url ?? ''
47
+
48
+ // 1. Custom binding path (e.g. `/europe/it`) — preserve it, matching resolveLink.
49
+ if (extractCustomPathPrefix(requestUrl) !== null) {
50
+ return addCustomPathPrefix(destination, requestUrl)
51
+ }
52
+
53
+ // 2. Non-default Next locale (e.g. `/it-IT`) — Next won't add it on redirects.
54
+ const { locale, defaultLocale } = context
55
+
56
+ if (!locale || !defaultLocale || locale === defaultLocale) {
57
+ return destination
58
+ }
59
+
60
+ // Already prefixed (defensive — avoids `/it-IT/it-IT/...`).
61
+ if (destination === `/${locale}` || destination.startsWith(`/${locale}/`)) {
62
+ return destination
63
+ }
64
+
65
+ return `/${locale}${destination}`
66
+ }
@@ -23,12 +23,6 @@ export function withLocaleValidationSSR<P extends Record<string, any>>(
23
23
  return getServerSidePropsFn(context)
24
24
  }
25
25
 
26
- // Skip locale validation for private/authenticated routes
27
- const pathname = req.url?.split('?')[0] ?? ''
28
- if (pathname.startsWith('/pvt/')) {
29
- return getServerSidePropsFn(context)
30
- }
31
-
32
26
  const isValid = validateLocaleForHostname(hostname, locale)
33
27
 
34
28
  if (!isValid) {
@@ -0,0 +1,122 @@
1
+ import { NextRequest, NextResponse } from 'next/server'
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+
4
+ const checkStoreProtectionMock = vi.hoisted(() => vi.fn())
5
+ const getSubdomainBindingsMock = vi.hoisted(() => vi.fn())
6
+ const getCustomPathsFromBindingsMock = vi.hoisted(() => vi.fn())
7
+ const isValidLocaleMock = vi.hoisted(() => vi.fn())
8
+
9
+ vi.mock('discovery.config', () => ({
10
+ __esModule: true,
11
+ default: {
12
+ localization: {
13
+ enabled: true,
14
+ locales: {
15
+ 'en-US': { bindings: [] },
16
+ 'pt-BR': { bindings: [] },
17
+ },
18
+ },
19
+ },
20
+ }))
21
+
22
+ vi.mock('../src/server/password-protection-service', () => ({
23
+ PasswordProtectionService: class {
24
+ checkStoreProtection = checkStoreProtectionMock
25
+ },
26
+ }))
27
+
28
+ vi.mock('src/utils/localization/bindingPaths', () => ({
29
+ getCustomPathsFromBindings: getCustomPathsFromBindingsMock,
30
+ getSubdomainBindings: getSubdomainBindingsMock,
31
+ isValidLocale: isValidLocaleMock,
32
+ }))
33
+
34
+ function localizedRequest(
35
+ pathname: string,
36
+ options?: { locale?: string; host?: string }
37
+ ): NextRequest {
38
+ const host = options?.host ?? 'localhost:3000'
39
+ const req = new NextRequest(`http://${host}${pathname}`, {
40
+ headers: { host },
41
+ })
42
+
43
+ if (options?.locale) {
44
+ Object.defineProperty(req.nextUrl, 'locale', {
45
+ value: options.locale,
46
+ writable: true,
47
+ configurable: true,
48
+ })
49
+ }
50
+
51
+ return req
52
+ }
53
+
54
+ describe('proxy localization rewrite', () => {
55
+ beforeEach(() => {
56
+ vi.clearAllMocks()
57
+ vi.resetModules()
58
+ checkStoreProtectionMock.mockResolvedValue({
59
+ response: NextResponse.next(),
60
+ })
61
+ getSubdomainBindingsMock.mockReturnValue([
62
+ { hostname: 'localhost', locale: 'en-US' },
63
+ ])
64
+ getCustomPathsFromBindingsMock.mockReturnValue([])
65
+ isValidLocaleMock.mockImplementation((locale: string) =>
66
+ ['en-US', 'pt-BR'].includes(locale)
67
+ )
68
+ })
69
+
70
+ it('skips subdomain rewrite when request locale differs from root binding locale', async () => {
71
+ const { proxy } = await import('../src/proxy')
72
+ const response = (await proxy(
73
+ localizedRequest('/pvt/account/profile', { locale: 'pt-BR' })
74
+ )) as NextResponse
75
+
76
+ expect(response.headers.get('x-middleware-rewrite')).toBeNull()
77
+ })
78
+
79
+ it('rewrites when request locale matches subdomain binding locale', async () => {
80
+ const { proxy } = await import('../src/proxy')
81
+ const response = (await proxy(
82
+ localizedRequest('/pvt/account/profile', { locale: 'en-US' })
83
+ )) as NextResponse
84
+
85
+ const rewrite = response.headers.get('x-middleware-rewrite')
86
+ expect(rewrite).toContain('/en-US/pvt/account/profile')
87
+ })
88
+
89
+ it('rewrites paths without a locale segment when no active locale is set', async () => {
90
+ const { proxy } = await import('../src/proxy')
91
+ const response = (await proxy(
92
+ localizedRequest('/pvt/account/profile')
93
+ )) as NextResponse
94
+
95
+ const rewrite = response.headers.get('x-middleware-rewrite')
96
+ expect(rewrite).toContain('/en-US/pvt/account/profile')
97
+ })
98
+
99
+ it('skips _next/data rewrite when data locale differs from subdomain locale', async () => {
100
+ const { proxy } = await import('../src/proxy')
101
+ const response = (await proxy(
102
+ localizedRequest(
103
+ '/_next/data/development/pt-BR/pvt/account/profile.json',
104
+ { locale: 'pt-BR' }
105
+ )
106
+ )) as NextResponse
107
+
108
+ expect(response.headers.get('x-middleware-rewrite')).toBeNull()
109
+ })
110
+
111
+ it('does not rewrite _next/data when data locale matches subdomain locale', async () => {
112
+ const { proxy } = await import('../src/proxy')
113
+ const response = (await proxy(
114
+ localizedRequest(
115
+ '/_next/data/development/en-US/pvt/account/profile.json',
116
+ { locale: 'en-US' }
117
+ )
118
+ )) as NextResponse
119
+
120
+ expect(response.headers.get('x-middleware-rewrite')).toBeNull()
121
+ })
122
+ })
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Tests for the localization reconcile seam.
3
+ *
4
+ * `reconcileSessionLocale` is wired into the SDK `persisted` middleware (via
5
+ * `createSessionStore({ reconcile })`) and runs every time a payload flows from
6
+ * IDB into memory — both on hydration and on the `focus`/`visibilitychange`
7
+ * cross-tab sync. It forces `locale`/`currency`/`salesChannel` to follow the
8
+ * URL (the single source of truth) while preserving every other field from the
9
+ * persisted payload. This replaces the previous one-shot `installLocaleCorrector`
10
+ * subscriber, which only fixed memory on the first hydration and never covered
11
+ * the focus sync.
12
+ */
13
+ import type { Session } from '@faststore/sdk'
14
+ import { afterEach, describe, expect, it, vi } from 'vitest'
15
+
16
+ vi.mock(import('../../../discovery.config.js'), async (original) => ({
17
+ default: {
18
+ ...((await original()).default ?? (await original())),
19
+ localization: {
20
+ enabled: true,
21
+ defaultLocale: 'pt-BR',
22
+ regions: {
23
+ BR: {
24
+ code: 'BR',
25
+ name: 'Brazil',
26
+ dateFormat: 'DD/MM/YYYY',
27
+ timeFormat: '24h',
28
+ timeFormatMask: 'HH:mm',
29
+ unitSystem: 'metric',
30
+ defaultTimezone: 'GMT-3',
31
+ },
32
+ US: {
33
+ code: 'US',
34
+ name: 'United States',
35
+ dateFormat: 'MM/DD/YYYY',
36
+ timeFormat: '12h',
37
+ timeFormatMask: 'hh:mm a',
38
+ unitSystem: 'imperial',
39
+ defaultTimezone: 'GMT-5',
40
+ },
41
+ },
42
+ locales: {
43
+ 'pt-BR': {
44
+ code: 'pt-BR',
45
+ name: 'Português',
46
+ languageCode: 'pt',
47
+ languageName: 'Portuguese',
48
+ script: 'Latn',
49
+ textDirection: 'ltr',
50
+ regionCode: 'BR',
51
+ bindings: [
52
+ {
53
+ currencyCode: 'BRL',
54
+ url: 'https://store.example.com/pt-BR',
55
+ salesChannel: '1',
56
+ isDefault: true,
57
+ },
58
+ ],
59
+ },
60
+ 'en-US': {
61
+ code: 'en-US',
62
+ name: 'English',
63
+ languageCode: 'en',
64
+ languageName: 'English',
65
+ script: 'Latn',
66
+ textDirection: 'ltr',
67
+ regionCode: 'US',
68
+ bindings: [
69
+ {
70
+ currencyCode: 'USD',
71
+ url: 'https://store.example.com/en-US',
72
+ salesChannel: '2',
73
+ isDefault: false,
74
+ },
75
+ ],
76
+ },
77
+ },
78
+ currencies: {
79
+ BRL: { code: 'BRL', name: 'Brazilian Real', symbol: 'R$' },
80
+ USD: { code: 'USD', name: 'US Dollar', symbol: '$' },
81
+ },
82
+ },
83
+ },
84
+ }))
85
+
86
+ import { reconcileSessionLocale } from '../../../src/sdk/session/initialSession'
87
+
88
+ const originalLocation = window.location
89
+
90
+ function stubHref(href: string) {
91
+ Object.defineProperty(window, 'location', {
92
+ configurable: true,
93
+ value: { ...originalLocation, href },
94
+ })
95
+ }
96
+
97
+ const STALE: Session = {
98
+ locale: 'en-US',
99
+ currency: { code: 'USD', symbol: '$' },
100
+ channel: '{"salesChannel":"2","regionId":"region-xyz"}',
101
+ country: 'USA',
102
+ deliveryMode: null,
103
+ addressType: null,
104
+ city: null,
105
+ postalCode: null,
106
+ geoCoordinates: null,
107
+ b2b: null,
108
+ person: null,
109
+ marketingData: null,
110
+ refreshAfter: null,
111
+ }
112
+
113
+ describe('reconcileSessionLocale (browser, localization enabled)', () => {
114
+ afterEach(() => {
115
+ Object.defineProperty(window, 'location', {
116
+ configurable: true,
117
+ value: originalLocation,
118
+ })
119
+ })
120
+
121
+ it('forces locale/currency from the URL when the IDB payload is stale', () => {
122
+ stubHref('https://store.example.com/pt-BR/pvt/some/path')
123
+
124
+ const result = reconcileSessionLocale(STALE)
125
+
126
+ expect(result.locale).toBe('pt-BR')
127
+ expect(result.currency).toEqual({ code: 'BRL', symbol: 'R$' })
128
+ })
129
+
130
+ it('preserves unrelated session fields from the payload (e.g. person)', () => {
131
+ stubHref('https://store.example.com/pt-BR/some/path')
132
+
133
+ const payload: Session = {
134
+ ...STALE,
135
+ person: { id: 'p1', email: 'a@b.com', givenName: 'A', familyName: 'B' },
136
+ postalCode: '12345',
137
+ }
138
+
139
+ const result = reconcileSessionLocale(payload)
140
+
141
+ expect(result.locale).toBe('pt-BR')
142
+ expect(result.postalCode).toBe('12345')
143
+ expect(result.person).toEqual({
144
+ id: 'p1',
145
+ email: 'a@b.com',
146
+ givenName: 'A',
147
+ familyName: 'B',
148
+ })
149
+ })
150
+
151
+ it('replaces salesChannel inside channel JSON while preserving non-locale channel fields', () => {
152
+ stubHref('https://store.example.com/pt-BR/some/path')
153
+
154
+ const payload: Session = {
155
+ ...STALE,
156
+ channel:
157
+ '{"salesChannel":"2","regionId":"region-xyz","customKey":"keep-me"}',
158
+ }
159
+
160
+ const result = reconcileSessionLocale(payload)
161
+ const channel = JSON.parse(result.channel ?? '{}')
162
+
163
+ expect(channel.salesChannel).toBe('1')
164
+ expect(channel.regionId).toBe('region-xyz')
165
+ expect(channel.customKey).toBe('keep-me')
166
+ })
167
+
168
+ it('survives a malformed channel JSON in the payload (does not throw, still reconciles)', () => {
169
+ stubHref('https://store.example.com/pt-BR/some/path')
170
+
171
+ const payload: Session = { ...STALE, channel: '{not valid json' }
172
+
173
+ let result: Session | undefined
174
+ expect(() => {
175
+ result = reconcileSessionLocale(payload)
176
+ }).not.toThrow()
177
+
178
+ expect(result?.locale).toBe('pt-BR')
179
+ const channel = JSON.parse(result?.channel ?? '{}')
180
+ expect(channel.salesChannel).toBe('1')
181
+ })
182
+
183
+ it('is idempotent when the payload already matches the URL locale', () => {
184
+ stubHref('https://store.example.com/pt-BR/some/path')
185
+
186
+ const aligned: Session = {
187
+ ...STALE,
188
+ locale: 'pt-BR',
189
+ currency: { code: 'BRL', symbol: 'R$' },
190
+ channel: '{"salesChannel":"1","regionId":"region-xyz"}',
191
+ }
192
+
193
+ const result = reconcileSessionLocale(aligned)
194
+
195
+ expect(result.locale).toBe('pt-BR')
196
+ expect(result.currency).toEqual({ code: 'BRL', symbol: 'R$' })
197
+ expect(JSON.parse(result.channel ?? '{}').salesChannel).toBe('1')
198
+ })
199
+ })
@@ -0,0 +1,38 @@
1
+ /**
2
+ * When localization is disabled, `reconcileSessionLocale` must be a no-op so
3
+ * the persisted middleware behaves exactly as before (raw IDB payload flows
4
+ * into memory untouched).
5
+ */
6
+ import type { Session } from '@faststore/sdk'
7
+ import { describe, expect, it, vi } from 'vitest'
8
+
9
+ vi.mock(import('../../../discovery.config.js'), async (original) => ({
10
+ default: {
11
+ ...((await original()).default ?? (await original())),
12
+ localization: undefined,
13
+ },
14
+ }))
15
+
16
+ import { reconcileSessionLocale } from '../../../src/sdk/session/initialSession'
17
+
18
+ const PAYLOAD: Session = {
19
+ locale: 'en-US',
20
+ currency: { code: 'USD', symbol: '$' },
21
+ channel: '{"salesChannel":"2","regionId":"region-xyz"}',
22
+ country: 'USA',
23
+ deliveryMode: null,
24
+ addressType: null,
25
+ city: null,
26
+ postalCode: null,
27
+ geoCoordinates: null,
28
+ b2b: null,
29
+ person: null,
30
+ marketingData: null,
31
+ refreshAfter: null,
32
+ }
33
+
34
+ describe('reconcileSessionLocale (localization disabled)', () => {
35
+ it('returns the payload unchanged', () => {
36
+ expect(reconcileSessionLocale(PAYLOAD)).toBe(PAYLOAD)
37
+ })
38
+ })