@faststore/core 4.5.1 → 4.6.0-dev.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.
@@ -16,6 +16,7 @@ import {
16
16
  GraphqlVtexSchema,
17
17
  isFastStoreError,
18
18
  } from '@faststore/api'
19
+ import { OTELAPI } from '@faststore/diagnostics'
19
20
  import { loadFilesSync } from '@graphql-tools/load-files'
20
21
  import { mergeTypeDefs } from '@graphql-tools/merge'
21
22
  import { makeExecutableSchema } from '@graphql-tools/schema'
@@ -28,7 +29,7 @@ import thirdPartyResolvers from '../customizations/src/graphql/thirdParty/resolv
28
29
  import vtexExtensionsResolvers from '../customizations/src/graphql/vtex/resolvers'
29
30
 
30
31
  import type { Operation } from '../sdk/graphql/request'
31
- import { apiOptions, withTraceClient } from './options'
32
+ import { apiOptions } from './options'
32
33
 
33
34
  interface ExecuteOptions<V = Record<string, unknown>> {
34
35
  operation: Operation
@@ -70,8 +71,7 @@ export function getFinalAPISchema() {
70
71
  }
71
72
 
72
73
  export const getEnvelop = async () => {
73
- const options = await withTraceClient(apiOptions)
74
- const apiContextFactory = await GraphqlVtexContextFactory(options)
74
+ const apiContextFactory = await GraphqlVtexContextFactory(apiOptions)
75
75
 
76
76
  return envelop({
77
77
  plugins: [
@@ -136,13 +136,17 @@ export const execute = async <V extends Maybe<{ [key: string]: unknown }>, D>(
136
136
  contextValue.storage.locale = locale
137
137
  }
138
138
 
139
- const { data, errors } = (await run({
140
- schema,
141
- document: parse(query),
142
- variableValues: variables,
143
- contextValue,
144
- operationName,
145
- })) as { data: D; errors: unknown[] }
139
+ const { data, errors } = await withRootSpan(
140
+ `graphql ${operationName ?? 'operation'}`,
141
+ () =>
142
+ run({
143
+ schema,
144
+ document: parse(query),
145
+ variableValues: variables,
146
+ contextValue,
147
+ operationName,
148
+ }) as Promise<{ data: D; errors: unknown[] }>
149
+ )
146
150
 
147
151
  return {
148
152
  data,
@@ -153,3 +157,27 @@ export const execute = async <V extends Maybe<{ [key: string]: unknown }>, D>(
153
157
  },
154
158
  }
155
159
  }
160
+
161
+ /**
162
+ * Runs an operation inside a per-request root span, made the active context so
163
+ * the resolver spans emitted by `@faststore/api` parent to it. Resolves to a
164
+ * plain call when telemetry is disabled.
165
+ */
166
+ async function withRootSpan<T>(name: string, run: () => Promise<T>) {
167
+ if (!apiOptions.OTEL_ENABLED) {
168
+ return run()
169
+ }
170
+
171
+ const span = OTELAPI.trace.getTracer('@faststore/core').startSpan(name)
172
+ const otelContext = OTELAPI.trace.setSpan(OTELAPI.context.active(), span)
173
+
174
+ try {
175
+ return await OTELAPI.context.with(otelContext, run)
176
+ } catch (error) {
177
+ span.setStatus({ code: OTELAPI.SpanStatusCode.ERROR })
178
+ span.recordException(error as Error)
179
+ throw error
180
+ } finally {
181
+ span.end()
182
+ }
183
+ }
@@ -1,9 +1,8 @@
1
1
  import type { APIOptions } from '@faststore/api'
2
- import { getTraceClient } from '@faststore/diagnostics'
3
2
  import storeConfig from '../../discovery.config'
4
3
  import pkgJSON from '../../package.json'
5
4
 
6
- const { name, version } = pkgJSON
5
+ const { version } = pkgJSON
7
6
 
8
7
  export const apiOptions: APIOptions = {
9
8
  platform: storeConfig.platform as APIOptions['platform'],
@@ -23,25 +22,7 @@ export const apiOptions: APIOptions = {
23
22
  storeConfig.api?.enableUnavailableItemsOnCart ?? false,
24
23
  },
25
24
  version,
26
- OTEL: {
27
- enabled: storeConfig.analytics.otelEnabled,
28
- },
25
+ // feature flag default to false as it should not emit traces/logs yet
26
+ OTEL_ENABLED: false,
29
27
  discoveryConfig: storeConfig,
30
28
  }
31
-
32
- export async function withTraceClient<T extends APIOptions = typeof apiOptions>(
33
- apiOptions: T
34
- ): Promise<T> {
35
- const OTEL = {}
36
- getTraceClient(
37
- apiOptions?.discoveryConfig?.analytics?.serviceName ?? name
38
- )?.inject(OTEL)
39
-
40
- return {
41
- ...apiOptions,
42
- OTEL: {
43
- ...OTEL,
44
- enabled: storeConfig.analytics?.otelEnabled?.toString() === 'true',
45
- },
46
- } as T
47
- }
@@ -3,18 +3,6 @@
3
3
  // Establish a layer order up-front, FS UI base styles have the lowest priority
4
4
  @layer base, components, theme;
5
5
 
6
- // Hide painted pixels during PLP scroll restore so the footer does not flash
7
- // at the top before the viewport jumps back to the saved product card.
8
- html.fs-restoring-scroll {
9
- overflow-x: hidden;
10
-
11
- body {
12
- // Hide from hit-testing and the accessibility tree while scroll jumps —
13
- // opacity: 0 would keep the page interactive/AT-visible.
14
- visibility: hidden;
15
- }
16
- }
17
-
18
6
  // FastStore UI's base styles
19
7
  @include meta.load-css("~@faststore/ui/src/styles/global.scss");
20
8
 
@@ -0,0 +1,27 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ import { register } from '../src/instrumentation'
4
+
5
+ const getTelemetryClient = vi.hoisted(() => vi.fn())
6
+
7
+ vi.mock('@faststore/diagnostics', () => ({ getTelemetryClient }))
8
+
9
+ beforeEach(() => {
10
+ vi.clearAllMocks()
11
+ })
12
+
13
+ afterEach(() => {
14
+ vi.unstubAllEnvs()
15
+ })
16
+
17
+ describe('register', () => {
18
+ it.each(['nodejs', 'edge', undefined])(
19
+ 'does not boot telemetry on the %s runtime while the feature is off',
20
+ async (runtime) => {
21
+ vi.stubEnv('NEXT_RUNTIME', runtime)
22
+
23
+ await expect(register()).resolves.toBeUndefined()
24
+ expect(getTelemetryClient).not.toHaveBeenCalled()
25
+ }
26
+ )
27
+ })
@@ -29,9 +29,13 @@ const createResponse = () => {
29
29
  return res as unknown as NextApiResponse & typeof res
30
30
  }
31
31
 
32
- const createRequest = (operationName = 'ClientShippingSimulationQuery') =>
32
+ const createRequest = (
33
+ operationName = 'ClientShippingSimulationQuery',
34
+ method = 'GET'
35
+ ) =>
33
36
  ({
34
- method: 'GET',
37
+ method,
38
+ url: '/api/graphql',
35
39
  headers: { host: 'localhost:3000' },
36
40
  query: {
37
41
  operationName,
@@ -165,3 +169,37 @@ describe('/api/graphql error status propagation', () => {
165
169
  expect(res.status).toHaveBeenCalledWith(404)
166
170
  })
167
171
  })
172
+
173
+ describe('/api/graphql request handling', () => {
174
+ beforeEach(() => {
175
+ vi.clearAllMocks()
176
+ })
177
+
178
+ it.each(['PUT', 'DELETE', 'PATCH', 'OPTIONS'])(
179
+ 'rejects %s with 405 without reaching the GraphQL layer',
180
+ async (method) => {
181
+ const res = createResponse()
182
+ await handler(createRequest(undefined, method), res)
183
+
184
+ expect(res.status).toHaveBeenCalledWith(405)
185
+ expect(mockedExecute).not.toHaveBeenCalled()
186
+ }
187
+ )
188
+
189
+ it.each([
190
+ ['BadRequestError', () => new BadRequestError('malformed'), 400],
191
+ ['UnauthorizedError', () => new UnauthorizedError('expired'), 401],
192
+ ['an unexpected failure', () => new Error('socket hang up'), 500],
193
+ ])(
194
+ 'maps %s thrown by execute to its status',
195
+ async (_name, makeError, status) => {
196
+ mockedExecute.mockRejectedValue(makeError())
197
+
198
+ const res = createResponse()
199
+ await handler(createRequest(), res)
200
+
201
+ expect(res.status).toHaveBeenCalledWith(status)
202
+ expect(res.end).toHaveBeenCalled()
203
+ }
204
+ )
205
+ })
@@ -0,0 +1,90 @@
1
+ import { OTELAPI } from '@faststore/diagnostics'
2
+ import {
3
+ afterAll,
4
+ afterEach,
5
+ beforeEach,
6
+ describe,
7
+ expect,
8
+ it,
9
+ vi,
10
+ } from 'vitest'
11
+
12
+ import { execute } from '../../src/server'
13
+ import { apiOptions } from '../../src/server/options'
14
+
15
+ const span = {
16
+ end: vi.fn(),
17
+ setStatus: vi.fn(),
18
+ recordException: vi.fn(),
19
+ }
20
+
21
+ const startSpan = vi.fn(() => span)
22
+
23
+ const otelEnabledByDefault = apiOptions.OTEL_ENABLED
24
+
25
+ // `__typename` resolves inside GraphQL itself, so these run the whole envelop
26
+ // pipeline without reaching the VTEX platform.
27
+ const runOperation = (query: string, operationName?: string) =>
28
+ execute({
29
+ operation: { __meta__: { operationName, operationHash: 'not-persisted' } },
30
+ variables: {},
31
+ query,
32
+ })
33
+
34
+ beforeEach(() => {
35
+ vi.clearAllMocks()
36
+ vi.spyOn(OTELAPI.trace, 'getTracer').mockReturnValue({ startSpan } as never)
37
+ apiOptions.OTEL_ENABLED = true
38
+ })
39
+
40
+ afterEach(() => {
41
+ vi.restoreAllMocks()
42
+ })
43
+
44
+ afterAll(() => {
45
+ apiOptions.OTEL_ENABLED = otelEnabledByDefault
46
+ })
47
+
48
+ describe('execute root span', () => {
49
+ it('wraps the operation in a span named after it', async () => {
50
+ const { data } = await runOperation(
51
+ 'query RootSpanQuery { __typename }',
52
+ 'RootSpanQuery'
53
+ )
54
+
55
+ expect(data).toEqual({ __typename: 'Query' })
56
+ expect(startSpan).toHaveBeenCalledWith('graphql RootSpanQuery')
57
+ expect(span.end).toHaveBeenCalledTimes(1)
58
+ expect(span.setStatus).not.toHaveBeenCalled()
59
+ })
60
+
61
+ it('names the span generically when the operation is anonymous', async () => {
62
+ await runOperation('{ __typename }')
63
+
64
+ expect(startSpan).toHaveBeenCalledWith('graphql operation')
65
+ })
66
+
67
+ it('marks the span as failed and ends it when the operation throws', async () => {
68
+ await expect(
69
+ runOperation('this is not graphql', 'BrokenQuery')
70
+ ).rejects.toThrow(/Syntax Error/)
71
+
72
+ expect(span.setStatus).toHaveBeenCalledWith({
73
+ code: OTELAPI.SpanStatusCode.ERROR,
74
+ })
75
+ expect(span.recordException).toHaveBeenCalledWith(expect.any(Error))
76
+ expect(span.end).toHaveBeenCalledTimes(1)
77
+ })
78
+
79
+ it('skips instrumentation entirely when telemetry is disabled', async () => {
80
+ apiOptions.OTEL_ENABLED = false
81
+
82
+ const { data } = await runOperation(
83
+ 'query RootSpanQuery { __typename }',
84
+ 'RootSpanQuery'
85
+ )
86
+
87
+ expect(data).toEqual({ __typename: 'Query' })
88
+ expect(startSpan).not.toHaveBeenCalled()
89
+ })
90
+ })
@@ -1,238 +0,0 @@
1
- import {
2
- useEffect,
3
- useMemo,
4
- useRef,
5
- useState,
6
- type CSSProperties,
7
- type PropsWithChildren,
8
- } from 'react'
9
- import { useSearch } from '@faststore/sdk'
10
-
11
- /** Coarse layout buckets — page height differs a lot across these. */
12
- export function getGalleryViewportBucket() {
13
- if (globalThis.window === undefined) return 'ssr'
14
- const width = globalThis.innerWidth
15
- if (width <= 420) return 'mobile'
16
- if (width <= 768) return 'tablet'
17
- if (width < 1280) return 'notebook'
18
- return 'desktop'
19
- }
20
-
21
- /** 32-bit string hash — avoids truncating long facet/path keys into collisions. */
22
- export function hashGalleryPageHeightKey(value: string) {
23
- let hash = 0
24
- for (const char of value) {
25
- hash = Math.trunc(Math.imul(31, hash) + (char.codePointAt(0) ?? 0))
26
- }
27
- return (hash >>> 0).toString(36)
28
- }
29
-
30
- export function buildGalleryPageHeightKey(
31
- page: number,
32
- search: {
33
- path: string
34
- term: string | null
35
- sort: string | null
36
- selectedFacets: unknown
37
- viewport?: string
38
- }
39
- ) {
40
- const viewport = search.viewport ?? getGalleryViewportBucket()
41
- const raw = JSON.stringify({
42
- path: search.path,
43
- term: search.term,
44
- sort: search.sort,
45
- selectedFacets: search.selectedFacets ?? [],
46
- page,
47
- viewport,
48
- })
49
- // Hash the full payload so long paths/facets never truncate page/viewport away.
50
- return `__fs_gallery_page_h_p${page}_${viewport}_${hashGalleryPageHeightKey(raw)}`
51
- }
52
-
53
- export function readGalleryPageHeight(key: string): number | null {
54
- try {
55
- const raw = sessionStorage.getItem(key)
56
- if (!raw) return null
57
- const height = Number(raw)
58
- return Number.isFinite(height) && height > 0 ? height : null
59
- } catch {
60
- return null
61
- }
62
- }
63
-
64
- function writeGalleryPageHeight(key: string, height: number) {
65
- try {
66
- sessionStorage.setItem(key, String(Math.round(height)))
67
- } catch {
68
- // ignore quota / private mode
69
- }
70
- }
71
-
72
- /**
73
- * Sum of last-measured heights for the active infinite-scroll pages.
74
- * Used to reserve space for the whole gallery before individual pages mount.
75
- */
76
- export function getReservedGalleryHeight(
77
- pages: number[],
78
- search: {
79
- path: string
80
- term: string | null
81
- sort: string | null
82
- selectedFacets: unknown
83
- viewport?: string
84
- }
85
- ) {
86
- return pages.reduce((sum, page) => {
87
- const height = readGalleryPageHeight(
88
- buildGalleryPageHeightKey(page, search)
89
- )
90
- return sum + (height ?? 0)
91
- }, 0)
92
- }
93
-
94
- /** Client-only reserved height for the results column while products load. */
95
- export function resolveReservedGalleryHeight(
96
- hasProductsLoaded: boolean,
97
- pages: number[],
98
- search: {
99
- path: string
100
- term: string | null
101
- sort: string | null
102
- selectedFacets: unknown
103
- viewport?: string
104
- }
105
- ) {
106
- if (hasProductsLoaded) return 0
107
- return getReservedGalleryHeight(pages, search)
108
- }
109
-
110
- type Props = PropsWithChildren<{
111
- page: number
112
- }>
113
-
114
- /**
115
- * Locks the gallery page slot to the last measured height **only while the
116
- * skeleton is showing**. Once real product cards are mounted, the lock is
117
- * released so a viewport resize cannot leave huge empty gaps between pages.
118
- */
119
- function GalleryPageHeightLock({ page, children }: Props) {
120
- const {
121
- state: { term, sort, selectedFacets },
122
- } = useSearch()
123
-
124
- const [viewport, setViewport] = useState(getGalleryViewportBucket)
125
-
126
- useEffect(() => {
127
- const onResize = () => setViewport(getGalleryViewportBucket())
128
- globalThis.addEventListener('resize', onResize)
129
- return () => globalThis.removeEventListener('resize', onResize)
130
- }, [])
131
-
132
- const storageKey = useMemo(() => {
133
- const path =
134
- globalThis.window === undefined ? '' : globalThis.location.pathname
135
- return buildGalleryPageHeightKey(page, {
136
- path,
137
- term: term ?? null,
138
- sort: sort ?? null,
139
- selectedFacets,
140
- viewport,
141
- })
142
- }, [term, sort, selectedFacets, page, viewport])
143
-
144
- const ref = useRef<HTMLDivElement>(null)
145
- /** Height applied only while this page has no product cards yet. */
146
- // Start null on SSR and first client paint; storageKey effect applies the
147
- // sessionStorage value after mount to avoid hydration mismatches.
148
- const [pendingMinHeight, setPendingMinHeight] = useState<number | null>(null)
149
- const [isLocked, setIsLocked] = useState(true)
150
-
151
- useEffect(() => {
152
- setPendingMinHeight(readGalleryPageHeight(storageKey))
153
- // Re-lock briefly when the viewport bucket / search key changes so back-nav
154
- // skeletons on a new breakpoint still get the right reserved height.
155
- setIsLocked(true)
156
- }, [storageKey])
157
-
158
- useEffect(() => {
159
- const el = ref.current
160
- if (!el) return
161
-
162
- const syncFromDom = () => {
163
- const hasProducts = Boolean(
164
- el.querySelector(
165
- '[data-fs-product-card], a[data-testid="product-link"]'
166
- )
167
- )
168
-
169
- if (!hasProducts) {
170
- // Skeleton / empty — keep (or restore) the reserved minHeight.
171
- setIsLocked(true)
172
- return
173
- }
174
-
175
- // Products are visible: drop the lock so natural height can shrink on
176
- // resize, then persist the unconstrained content height for next back-nav.
177
- setIsLocked(false)
178
-
179
- // Measure after minHeight is cleared (next frame).
180
- requestAnimationFrame(() => {
181
- const height = el.getBoundingClientRect().height
182
- if (height < 80) return
183
- const rounded = Math.round(height)
184
- writeGalleryPageHeight(storageKey, rounded)
185
- setPendingMinHeight(rounded)
186
- })
187
- }
188
-
189
- syncFromDom()
190
-
191
- const mutationObserver = new MutationObserver(syncFromDom)
192
- mutationObserver.observe(el, { childList: true, subtree: true })
193
-
194
- let resizeObserver: ResizeObserver | null = null
195
- if (typeof ResizeObserver !== 'undefined') {
196
- resizeObserver = new ResizeObserver(() => {
197
- // Only persist when unlocked (real layout). Observing while locked
198
- // would re-read the artificial minHeight and never shrink.
199
- if (
200
- !el.querySelector(
201
- '[data-fs-product-card], a[data-testid="product-link"]'
202
- )
203
- ) {
204
- return
205
- }
206
- const height = el.getBoundingClientRect().height
207
- if (height < 80) return
208
- const rounded = Math.round(height)
209
- writeGalleryPageHeight(storageKey, rounded)
210
- setPendingMinHeight(rounded)
211
- })
212
- resizeObserver.observe(el)
213
- }
214
-
215
- return () => {
216
- mutationObserver.disconnect()
217
- resizeObserver?.disconnect()
218
- }
219
- // Re-sync when children swap (e.g. skeleton → product grid). MutationObserver
220
- // alone is not reliable across React rerenders in jsdom/CI.
221
- }, [storageKey, children])
222
-
223
- const style: CSSProperties | undefined =
224
- isLocked && pendingMinHeight ? { minHeight: pendingMinHeight } : undefined
225
-
226
- return (
227
- <div
228
- ref={ref}
229
- data-fs-gallery-page-height-lock
230
- data-fs-gallery-page={page}
231
- style={style}
232
- >
233
- {children}
234
- </div>
235
- )
236
- }
237
-
238
- export default GalleryPageHeightLock
@@ -1,32 +0,0 @@
1
- /**
2
- * @vitest-environment jsdom
3
- */
4
-
5
- import { render, screen } from '@testing-library/react'
6
- import { describe, expect, it, vi } from 'vitest'
7
-
8
- vi.mock('src/components/skeletons/ProductCardSkeleton', () => ({
9
- __esModule: true,
10
- default: () => <div data-testid="product-card-skeleton" />,
11
- }))
12
-
13
- import ProductGridSkeleton from '../../../src/components/skeletons/ProductGridSkeleton/ProductGridSkeleton'
14
-
15
- describe('ProductGridSkeleton', () => {
16
- it('renders the requested number of placeholder cards', () => {
17
- render(<ProductGridSkeleton loading count={4} />)
18
-
19
- expect(screen.getAllByTestId('product-card-skeleton')).toHaveLength(4)
20
- })
21
-
22
- it('renders children when not loading', () => {
23
- render(
24
- <ProductGridSkeleton loading={false}>
25
- <div data-testid="grid-content">products</div>
26
- </ProductGridSkeleton>
27
- )
28
-
29
- expect(screen.getByTestId('grid-content')).toBeTruthy()
30
- expect(screen.queryByTestId('product-card-skeleton')).toBeNull()
31
- })
32
- })