@wix/zero-config-implementation 1.85.0 → 1.87.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.
@@ -0,0 +1,126 @@
1
+ import { createServicesManager } from '@wix/services-manager'
2
+ import type { ServicesManager, ServicesRegistrar } from '@wix/services-manager/types'
3
+
4
+ /** Definitions are plain strings at runtime, so one doubles as both registry key and service id. */
5
+ type ServiceDefinitionLike = { toString(): string }
6
+
7
+ /** A thenable stub would hang `await`; a stub with `$$typeof` would be read as a React element. */
8
+ const NEVER_STUBBED_PROPERTIES: ReadonlySet<PropertyKey> = new Set([
9
+ 'then',
10
+ 'catch',
11
+ 'finally',
12
+ '$$typeof',
13
+ 'toJSON',
14
+ 'nodeType',
15
+ ])
16
+
17
+ const HANDLED_SYMBOLS: ReadonlySet<symbol> = new Set([Symbol.toPrimitive, Symbol.toStringTag, Symbol.iterator])
18
+
19
+ // Shared, so `stub.toString === stub.toString` holds.
20
+ const readAsEmptyString = (): string => ''
21
+ function* iterateAsEmpty(): Generator<never> {}
22
+
23
+ // Not `Symbol.for`, whose key is global and could collide with another library's.
24
+ const CALL_RESULT_KEY = Symbol('call-result')
25
+
26
+ /**
27
+ * Inert but still truthy — a component branching on a stubbed flag takes the truthy path, which
28
+ * is why stubbed services are reported to the caller.
29
+ */
30
+ function createServiceStub(servicePath: string): unknown {
31
+ const childStubs = new Map<PropertyKey, unknown>()
32
+
33
+ // Arrow function: callable, and owns no non-configurable props that would make the
34
+ // `ownKeys`/`getOwnPropertyDescriptor` traps below violate the Proxy invariants.
35
+ const callableTarget = (): void => {}
36
+
37
+ return new Proxy(callableTarget, {
38
+ get(_target, property) {
39
+ if (NEVER_STUBBED_PROPERTIES.has(property)) {
40
+ return undefined
41
+ }
42
+ if (property === Symbol.toPrimitive || property === 'valueOf' || property === 'toString') {
43
+ return readAsEmptyString
44
+ }
45
+ if (property === Symbol.toStringTag) {
46
+ return 'WixServiceStub'
47
+ }
48
+ if (property === Symbol.iterator) {
49
+ return iterateAsEmpty
50
+ }
51
+ if (typeof property === 'symbol') {
52
+ return undefined
53
+ }
54
+
55
+ const existingChildStub = childStubs.get(property)
56
+ if (existingChildStub !== undefined) {
57
+ return existingChildStub
58
+ }
59
+
60
+ const childStub = createServiceStub(`${servicePath}.${property}`)
61
+ childStubs.set(property, childStub)
62
+ return childStub
63
+ },
64
+
65
+ apply() {
66
+ const existingCallResult = childStubs.get(CALL_RESULT_KEY)
67
+ if (existingCallResult !== undefined) {
68
+ return existingCallResult
69
+ }
70
+
71
+ const callResult = createServiceStub(`${servicePath}()`)
72
+ childStubs.set(CALL_RESULT_KEY, callResult)
73
+ return callResult
74
+ },
75
+
76
+ // Mirrors `get`, so `in` never reports a property that reads back as undefined.
77
+ has: (_target, property) => {
78
+ if (NEVER_STUBBED_PROPERTIES.has(property)) {
79
+ return false
80
+ }
81
+ return typeof property === 'symbol' ? HANDLED_SYMBOLS.has(property) : true
82
+ },
83
+ set: () => true,
84
+ // No own keys, so spreading terminates instead of recursing forever.
85
+ ownKeys: () => [],
86
+ getOwnPropertyDescriptor: () => undefined,
87
+ })
88
+ }
89
+
90
+ export interface AutoStubFallbackOptions {
91
+ /** Invoked once per distinct service id. */
92
+ onServiceStubbed?: (serviceId: string) => void
93
+ }
94
+
95
+ /**
96
+ * `createServicesManager` delegates to its `parentServicesManager` for unbound definitions, so
97
+ * passing this as the parent turns a "Service X is not provided" throw into an inert stub.
98
+ */
99
+ export function createAutoStubFallbackManager(options: AutoStubFallbackOptions = {}): ServicesManager {
100
+ const stubsByServiceId = new Map<string, unknown>()
101
+
102
+ const getService = (definition: ServiceDefinitionLike): never => {
103
+ const serviceId = definition.toString()
104
+
105
+ const existingStub = stubsByServiceId.get(serviceId)
106
+ if (existingStub !== undefined) {
107
+ return existingStub as never
108
+ }
109
+
110
+ const stub = createServiceStub(serviceId)
111
+ stubsByServiceId.set(serviceId, stub)
112
+ options.onServiceStubbed?.(serviceId)
113
+ return stub as never
114
+ }
115
+
116
+ const fallbackManager: ServicesManager = {
117
+ getService: getService as ServicesManager['getService'],
118
+ hasService: () => true,
119
+ addService: () => {},
120
+ addServices: () => {},
121
+ dispose: () => {},
122
+ extend: (servicesMap: ServicesRegistrar) => createServicesManager(servicesMap, fallbackManager),
123
+ }
124
+
125
+ return fallbackManager
126
+ }
@@ -0,0 +1,63 @@
1
+ import { useService } from '@wix/services-manager-react'
2
+ import type { ServiceDefinition } from '@wix/services-manager/types'
3
+ import { TranslationsDefinition } from '@wix/viewer-service-translations/definition'
4
+ import React, { type ComponentType } from 'react'
5
+ import { renderToStaticMarkup } from 'react-dom/server'
6
+ import { describe, expect, it, vi } from 'vitest'
7
+ import { wrapWithWixServices } from './wrapper'
8
+
9
+ const UnregisteredDefinition = '@wix/example-unregistered-analytics' as unknown as ServiceDefinition<
10
+ { trackingId: string },
11
+ never
12
+ >
13
+
14
+ function renderThroughWrapper(Component: ComponentType<unknown>): string {
15
+ return renderToStaticMarkup(React.createElement(wrapWithWixServices(Component), {}))
16
+ }
17
+
18
+ describe('wrapWithWixServices', () => {
19
+ it('resolves a registered service to its real implementation, stubbing nothing', () => {
20
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
21
+
22
+ const TranslatingComponent: ComponentType<unknown> = () => {
23
+ const translations = useService(TranslationsDefinition)
24
+ return React.createElement('span', null, translations.translate('app')('greeting') ?? 'fallback-text')
25
+ }
26
+
27
+ // The real service echoes the key back; a stub would have produced an empty string.
28
+ expect(renderThroughWrapper(TranslatingComponent)).toContain('<span>greeting</span>')
29
+ expect(warn).not.toHaveBeenCalled()
30
+
31
+ warn.mockRestore()
32
+ })
33
+
34
+ it('warns per extraction', () => {
35
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
36
+
37
+ const AnalyticsComponent: ComponentType<unknown> = () => {
38
+ useService(UnregisteredDefinition)
39
+ return null
40
+ }
41
+
42
+ // A module-level dedupe would leave every component after the first silently degraded.
43
+ renderThroughWrapper(AnalyticsComponent)
44
+ renderThroughWrapper(AnalyticsComponent)
45
+
46
+ expect(warn).toHaveBeenCalledTimes(2)
47
+
48
+ warn.mockRestore()
49
+ })
50
+
51
+ it('renders a component that reads an unregistered service instead of throwing', () => {
52
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
53
+
54
+ const AnalyticsComponent: ComponentType<unknown> = () => {
55
+ const analytics = useService(UnregisteredDefinition)
56
+ return React.createElement('div', { 'data-tracking-id': String(analytics.trackingId) }, 'rendered')
57
+ }
58
+
59
+ expect(renderThroughWrapper(AnalyticsComponent)).toContain('<div data-tracking-id="">rendered</div>')
60
+
61
+ warn.mockRestore()
62
+ })
63
+ })
@@ -0,0 +1,204 @@
1
+ import { SeoServiceDefinition } from '@wix/seo-service/definition'
2
+ import { createSeoService } from '@wix/seo-service/implementations'
3
+ import { createServicesManager, createServicesMap } from '@wix/services-manager'
4
+ import { ServicesManagerProvider } from '@wix/services-manager-react'
5
+ import {
6
+ ProvideComponentServiceDefinition,
7
+ ProvideComponentServiceFactory,
8
+ } from '@wix/services-manager-react/core-services/provide-component'
9
+ import type { ServiceBinding } from '@wix/services-manager/types'
10
+ import { ConfigurationDefinition } from '@wix/site-service-configuration/definition'
11
+ import { ConfigurationService } from '@wix/site-service-configuration/implementations'
12
+ import { DeviceInfoDefinition } from '@wix/site-service-device-info/definition'
13
+ import { DeviceInfoService } from '@wix/site-service-device-info/implementations'
14
+ import { EditorContextDefinition } from '@wix/site-service-editor-context/definition'
15
+ import { EditorContextService } from '@wix/site-service-editor-context/implementations'
16
+ import { ExperimentsDefinition } from '@wix/site-service-experiments/definition'
17
+ import { ExperimentsService } from '@wix/site-service-experiments/implementations'
18
+ import { LocaleDefinition } from '@wix/site-service-locale/definition'
19
+ import { LocaleService } from '@wix/site-service-locale/implementations'
20
+ import type { IPagesServiceConfig, Page } from '@wix/site-service-pages/definition'
21
+ import { PagesDefinition } from '@wix/site-service-pages/definition'
22
+ import { PagesService } from '@wix/site-service-pages/implementations'
23
+ import { ProvideCssDefinition as SiteProvideCssDefinition } from '@wix/site-service-provide-css/definition'
24
+ import { ProvideCssService as SiteProvideCssService } from '@wix/site-service-provide-css/implementations'
25
+ import { RenderingContextDefinition } from '@wix/site-service-rendering-context/definition'
26
+ import { RenderingContextService } from '@wix/site-service-rendering-context/implementations'
27
+ import { UrlDefinition as SiteUrlDefinition } from '@wix/site-service-url/definition'
28
+ import { UrlService as SiteUrlService } from '@wix/site-service-url/implementations'
29
+ import { AnchorsDefinition } from '@wix/viewer-service-anchors/definition'
30
+ import { AnchorsService } from '@wix/viewer-service-anchors/implementations'
31
+ import { ConsentPolicyDefinition } from '@wix/viewer-service-consent-policy/definition'
32
+ import { ConsentPolicyService } from '@wix/viewer-service-consent-policy/implementations'
33
+ import { EnvironmentDefinition } from '@wix/viewer-service-environment/definition'
34
+ import { EnvironmentService } from '@wix/viewer-service-environment/implementations'
35
+ import { LinkUtilsDefinition } from '@wix/viewer-service-link-utils/definition'
36
+ import { LinkUtilsService } from '@wix/viewer-service-link-utils/implementations'
37
+ import { NamedSignalsDefinition } from '@wix/viewer-service-named-signals/definition'
38
+ import { NamedSignalsService } from '@wix/viewer-service-named-signals/implementations'
39
+ import type { IPagesServiceConfig as IViewerPagesServiceConfig } from '@wix/viewer-service-pages/definition'
40
+ import { PagesDefinition as ViewerPagesDefinition } from '@wix/viewer-service-pages/definition'
41
+ import { PagesService as ViewerPagesService } from '@wix/viewer-service-pages/implementations'
42
+ import { ProvideCssDefinition } from '@wix/viewer-service-provide-css/definition'
43
+ import { ProvideCssService } from '@wix/viewer-service-provide-css/implementations'
44
+ import { RendererConfigurationDefinition } from '@wix/viewer-service-renderer-configuration/definition'
45
+ import { RendererConfigurationService } from '@wix/viewer-service-renderer-configuration/implementations'
46
+ import { SdkStateDefinition } from '@wix/viewer-service-sdk-state/definition'
47
+ import { SdkStateService } from '@wix/viewer-service-sdk-state/implementations'
48
+ import { SiteScrollBlockerDefinition } from '@wix/viewer-service-site-scroll-blocker/definition'
49
+ import { SiteScrollBlockerService } from '@wix/viewer-service-site-scroll-blocker/implementations'
50
+ import type { Topology } from '@wix/viewer-service-topology/definition'
51
+ import { TopologyDefinition } from '@wix/viewer-service-topology/definition'
52
+ import { TopologyService } from '@wix/viewer-service-topology/implementations'
53
+ import { TranslationsDefinition } from '@wix/viewer-service-translations/definition'
54
+ import { TranslationsService } from '@wix/viewer-service-translations/implementations'
55
+ import { UrlDefinition } from '@wix/viewer-service-url/definition'
56
+ import { UrlService } from '@wix/viewer-service-url/implementations'
57
+ import React, { type ComponentType, type ReactNode } from 'react'
58
+
59
+ import { createAutoStubFallbackManager } from './service-stub'
60
+
61
+ const PLACEHOLDER_SITE_URL = 'https://wix.com'
62
+ const PLACEHOLDER_EXPERIMENTS = {
63
+ 'specs.thunderbolt.isClassNameToRootEnabled': true,
64
+ }
65
+ const PLACEHOLDER_HOME_PAGE: Page = {
66
+ id: 'home',
67
+ title: 'Home',
68
+ path: '/',
69
+ popup: false,
70
+ }
71
+ const PLACEHOLDER_CURRENT_PAGE: Page = {
72
+ id: 'audio',
73
+ title: 'Audio',
74
+ path: '/shop/audio',
75
+ parentPageId: 'shop',
76
+ popup: false,
77
+ }
78
+ const PLACEHOLDER_PAGES_CONFIG: IPagesServiceConfig = {
79
+ pages: {
80
+ home: { title: 'Home', path: '/', popup: false },
81
+ shop: { title: 'Shop', path: '/shop', parentPageId: 'home', popup: false },
82
+ audio: { title: 'Audio', path: '/shop/audio', parentPageId: 'shop', popup: false },
83
+ },
84
+ mainPage: PLACEHOLDER_HOME_PAGE,
85
+ currentPage: PLACEHOLDER_CURRENT_PAGE,
86
+ }
87
+
88
+ /** Derived, so the deprecated pages service and its successor describe the same site. */
89
+ const PLACEHOLDER_VIEWER_PAGES_CONFIG: IViewerPagesServiceConfig = {
90
+ pages: Object.fromEntries(
91
+ Object.entries(PLACEHOLDER_PAGES_CONFIG.pages).map(([pageId, { title, path, parentPageId }]) => [
92
+ pageId,
93
+ { title, path, parentPageId },
94
+ ]),
95
+ ),
96
+ currentPageId: PLACEHOLDER_PAGES_CONFIG.currentPage.id,
97
+ mainPageId: PLACEHOLDER_PAGES_CONFIG.mainPage.id,
98
+ }
99
+
100
+ const PLACEHOLDER_TOPOLOGY: Topology = {
101
+ mediaRootUrl: 'https://static.wixstatic.com/',
102
+ staticMediaUrl: 'https://static.wixstatic.com/media',
103
+ fileRepoUrl: 'https://static.wixstatic.com/ugd',
104
+ staticHTMLComponentUrl: 'https://static.wixstatic.com/html',
105
+ scriptsUrl: 'https://static.parastorage.com/',
106
+ }
107
+
108
+ // biome-ignore lint/suspicious/noExplicitAny: service bindings are heterogeneous by construction
109
+ type AnyServiceBinding = ServiceBinding<any, any>
110
+
111
+ /** Anything omitted here resolves through {@link createAutoStubFallbackManager} instead. */
112
+ function createServiceBindings(): AnyServiceBinding[] {
113
+ return [
114
+ {
115
+ definition: EnvironmentDefinition,
116
+ impl: EnvironmentService,
117
+ config: { experiments: PLACEHOLDER_EXPERIMENTS },
118
+ },
119
+ { definition: TranslationsDefinition, impl: TranslationsService, config: { translations: {} } },
120
+ {
121
+ definition: UrlDefinition,
122
+ impl: UrlService,
123
+ config: { externalBaseUrl: PLACEHOLDER_SITE_URL, requestUrl: PLACEHOLDER_SITE_URL },
124
+ },
125
+ { definition: LinkUtilsDefinition, impl: LinkUtilsService, config: {} },
126
+ { definition: AnchorsDefinition, impl: AnchorsService, config: {} },
127
+ { definition: SiteScrollBlockerDefinition, impl: SiteScrollBlockerService, config: {} },
128
+ { definition: ProvideCssDefinition, impl: ProvideCssService, config: {} },
129
+ { definition: RenderingContextDefinition, impl: RenderingContextService, config: {} },
130
+ { definition: ExperimentsDefinition, impl: ExperimentsService, config: { experiments: PLACEHOLDER_EXPERIMENTS } },
131
+ {
132
+ definition: ConfigurationDefinition,
133
+ impl: ConfigurationService,
134
+ config: { qaMode: false, trackClicksAnalytics: false, sandboxInHTMLComp: false },
135
+ },
136
+ { definition: SiteProvideCssDefinition, impl: SiteProvideCssService, config: {} },
137
+ { definition: DeviceInfoDefinition, impl: DeviceInfoService, config: {} },
138
+ { definition: EditorContextDefinition, impl: EditorContextService, config: { previewMode: false } },
139
+ {
140
+ definition: SiteUrlDefinition,
141
+ impl: SiteUrlService,
142
+ config: {
143
+ currentUrl: PLACEHOLDER_SITE_URL,
144
+ siteUrl: PLACEHOLDER_SITE_URL,
145
+ pages: [],
146
+ pageIdToPrefix: {},
147
+ },
148
+ },
149
+ { definition: PagesDefinition, impl: PagesService, config: PLACEHOLDER_PAGES_CONFIG },
150
+ { definition: ViewerPagesDefinition, impl: ViewerPagesService, config: PLACEHOLDER_VIEWER_PAGES_CONFIG },
151
+ { definition: LocaleDefinition, impl: LocaleService, config: { language: 'en', direction: 'ltr' } },
152
+ { definition: TopologyDefinition, impl: TopologyService, config: { topology: PLACEHOLDER_TOPOLOGY } },
153
+ {
154
+ definition: RendererConfigurationDefinition,
155
+ impl: RendererConfigurationService,
156
+ config: { isHipaaCompliant: false, currency: 'USD' },
157
+ },
158
+ { definition: SdkStateDefinition, impl: SdkStateService, config: {} },
159
+ { definition: NamedSignalsDefinition, impl: NamedSignalsService, config: {} },
160
+ { definition: ConsentPolicyDefinition, impl: ConsentPolicyService, config: { siteConsentPolicy: undefined } },
161
+ {
162
+ definition: SeoServiceDefinition,
163
+ impl: createSeoService,
164
+ config: { seoApi: { setVeloSeoTags: async () => {}, resetVeloSeoTags: async () => {} } },
165
+ },
166
+ { definition: ProvideComponentServiceDefinition, impl: ProvideComponentServiceFactory, config: {} },
167
+ ]
168
+ }
169
+
170
+ /** Dedupes per wrapper instance, so every extraction reports its own stubs. */
171
+ function createStubbedServiceReporter(): (serviceId: string) => void {
172
+ const reportedServiceIds = new Set<string>()
173
+
174
+ return (serviceId) => {
175
+ if (reportedServiceIds.has(serviceId)) {
176
+ return
177
+ }
178
+ reportedServiceIds.add(serviceId)
179
+ console.warn(
180
+ `[zero-config] Service "${serviceId}" is not registered by the extraction wrapper and was replaced with an inert stub. Branches depending on it may be missing from the extracted manifest.`,
181
+ )
182
+ }
183
+ }
184
+
185
+ /** Local replacement for `@wix/builder-services-wrapper`'s `WixServicesWrapper`. */
186
+ export function WixServicesWrapper({ children }: { children?: ReactNode }): React.ReactElement {
187
+ // Memoized: the manager eagerly constructs every registered service on creation.
188
+ const servicesManager = React.useMemo(
189
+ () =>
190
+ createServicesManager(
191
+ createServicesMap(createServiceBindings()),
192
+ createAutoStubFallbackManager({ onServiceStubbed: createStubbedServiceReporter() }),
193
+ ),
194
+ [],
195
+ )
196
+
197
+ return React.createElement(ServicesManagerProvider, { servicesManager }, children)
198
+ }
199
+
200
+ export function wrapWithWixServices(Component: ComponentType<unknown>): ComponentType<unknown> {
201
+ const WrappedComponent: ComponentType<unknown> = (props) =>
202
+ React.createElement(WixServicesWrapper, null, React.createElement(Component, props as Record<string, unknown>))
203
+ return WrappedComponent
204
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { WixServicesWrapper } from '@wix/builder-services-wrapper'
2
1
  import type { EditorReactComponent } from '@wix/react-component-schema'
3
2
  import { Result, ResultAsync } from 'neverthrow'
4
3
  import React, { type ComponentType } from 'react'
@@ -9,6 +8,7 @@ import { buildContextProviderModules } from './extensions/context-providers/cont
9
8
  import { buildContextAwareWrapper, loadMockProviders } from './extensions/context-providers/mock-provider'
10
9
  import { buildRefElementContext } from './extensions/ref-elements/context'
11
10
  import type { RefElementContext } from './extensions/ref-elements/types'
11
+ import { wrapWithWixServices } from './extensions/wix-services/wrapper'
12
12
  import type { ExtractionError } from './extraction-types'
13
13
  import type { RunExtractorsOptions } from './information-extractors/react'
14
14
  import { extractCssImports, extractDefaultComponentInfo } from './information-extractors/ts'
@@ -17,11 +17,7 @@ import { processComponent } from './manifest-pipeline'
17
17
  import { findComponent, findDefaultComponent, loadModuleForExtraction } from './module-loader'
18
18
  import { compileTsFile } from './ts-compiler'
19
19
 
20
- const defaultWrapper = (Component: ComponentType<unknown>): ComponentType<unknown> => {
21
- const WrappedComponent: ComponentType<unknown> = (props) =>
22
- React.createElement(WixServicesWrapper, null, React.createElement(Component, props as Record<string, unknown>))
23
- return WrappedComponent
24
- }
20
+ const defaultWrapper = wrapWithWixServices
25
21
 
26
22
  // ─────────────────────────────────────────────────────────────────────────────
27
23
  // Types
@@ -18,6 +18,11 @@ const inheritedChildrenFixture = path.resolve(
18
18
  '../../../../example-components/src/components/InheritedChildren/component.tsx',
19
19
  )
20
20
 
21
+ const activeItemIndexFixture = path.resolve(
22
+ __dirname,
23
+ '../../../../example-components/src/components/ActiveItemIndex/SimpleTabs/component.tsx',
24
+ )
25
+
21
26
  describe('TS extractor — ElementState<> trigger detection', () => {
22
27
  it('flags an ElementState<>-wrapped prop and unwraps its inner type', async () => {
23
28
  const programResult = await compileTsFile(propTriggeredFixture)
@@ -79,3 +84,35 @@ describe('TS extractor — container children props', () => {
79
84
  expect(component!.props.label).toBeDefined()
80
85
  })
81
86
  })
87
+
88
+ describe('TS extractor — ActiveItemIndex<> detection', () => {
89
+ it('flags an ActiveItemIndex<>-wrapped prop with the target array prop name and unwraps to number', async () => {
90
+ const programResult = await compileTsFile(activeItemIndexFixture)
91
+ expect(programResult.isOk()).toBe(true)
92
+ const program = programResult._unsafeUnwrap()
93
+
94
+ const components = extractAllComponentInfo(program, activeItemIndexFixture)
95
+ const component = components.find((candidate) => candidate.componentName === 'SimpleTabs')
96
+ expect(component).toBeDefined()
97
+
98
+ const activeTab = component!.props.activeTab
99
+ expect(activeTab.activeItemIndexTarget).toBe('tabs')
100
+ // The `ActiveItemIndex<'tabs'>` wrapper is unwrapped — the prop behaves as a plain number.
101
+ expect(activeTab.type).toBe('number')
102
+ // It should not be flagged as a state trigger.
103
+ expect(activeTab.isStateTrigger).toBeUndefined()
104
+ })
105
+
106
+ it('does not flag a plain number prop as an active item index', async () => {
107
+ const programResult = await compileTsFile(activeItemIndexFixture)
108
+ expect(programResult.isOk()).toBe(true)
109
+ const program = programResult._unsafeUnwrap()
110
+
111
+ const components = extractAllComponentInfo(program, activeItemIndexFixture)
112
+ const component = components.find((candidate) => candidate.componentName === 'SimpleTabs')
113
+ expect(component).toBeDefined()
114
+
115
+ // `tabs` is an array prop, not an active item index.
116
+ expect(component!.props.tabs.activeItemIndexTarget).toBeUndefined()
117
+ })
118
+ })
@@ -211,25 +211,32 @@ function convertComponentDoc(doc: ComponentDoc, program: ts.Program, checker: ts
211
211
  // Resolve the type deeply
212
212
  let declaredTypeInfo: DeclaredTypeInfo | undefined
213
213
  let stateTrigger: StateTriggerInfo | undefined
214
+ let activeItemIndex: ActiveItemIndexInfo | undefined
214
215
  if (propSymbol) {
215
216
  const decl = propSymbol.getDeclarations()?.[0]
216
217
  if (decl) {
217
218
  const declSourceFile = decl.getSourceFile()
218
219
  declaredTypeInfo = getDeclaredTypeInfo(propSymbol, declSourceFile, checker)
219
220
  stateTrigger = detectStateTrigger(propSymbol, declSourceFile, checker)
221
+ activeItemIndex = detectActiveItemIndex(propSymbol, declSourceFile, checker)
220
222
  }
221
223
  }
222
224
 
223
225
  // An `ElementState<Inner>` prop should behave as its inner type everywhere except
224
226
  // for the `isStateTrigger` flag, so unwrap the inner type for `type` and resolution
225
227
  // (and drop the `ElementState` declared symbol so it isn't mistaken for a semantic type).
226
- const typeString = stateTrigger?.innerTypeName ?? declaredTypeInfo?.name ?? propItem.type.name
228
+ // Similarly, `ActiveItemIndex<'arrayPropName'>` unwraps to `number`.
229
+ const typeString =
230
+ stateTrigger?.innerTypeName ??
231
+ (activeItemIndex ? 'number' : undefined) ??
232
+ declaredTypeInfo?.name ??
233
+ propItem.type.name
227
234
  const resolvedType = propType
228
235
  ? resolveType({
229
236
  type: propType,
230
237
  checker,
231
238
  typeString,
232
- declaredSymbol: stateTrigger ? undefined : declaredTypeInfo?.symbol,
239
+ declaredSymbol: stateTrigger || activeItemIndex ? undefined : declaredTypeInfo?.symbol,
233
240
  })
234
241
  : { kind: 'primitive' as const, value: propItem.type.name }
235
242
 
@@ -255,6 +262,7 @@ function convertComponentDoc(doc: ComponentDoc, program: ts.Program, checker: ts
255
262
  min: parseNumericTag(tags?.min, propName, 'min'),
256
263
  max: parseNumericTag(tags?.max, propName, 'max'),
257
264
  isStateTrigger: stateTrigger ? true : undefined,
265
+ activeItemIndexTarget: activeItemIndex?.targetArrayProp,
258
266
  }
259
267
  }
260
268
 
@@ -506,6 +514,11 @@ interface StateTriggerInfo {
506
514
  innerTypeName: string
507
515
  }
508
516
 
517
+ interface ActiveItemIndexInfo {
518
+ /** The string literal from the type argument, naming the target array prop. */
519
+ targetArrayProp: string
520
+ }
521
+
509
522
  /**
510
523
  * Detects when a prop is declared with the `ElementState<>` marker type from
511
524
  * `@wix/react-component-utils` (e.g. `isLoading?: ElementState<boolean>`), which opts
@@ -558,6 +571,65 @@ function isElementStateAliasFromUtils(identifier: ts.Identifier, checker: ts.Typ
558
571
  return declaration.getSourceFile().fileName.includes('react-component-utils')
559
572
  }
560
573
 
574
+ /**
575
+ * Detects when a prop is declared with the `ActiveItemIndex<>` marker type from
576
+ * `@wix/react-component-utils` (e.g. `activeTab?: ActiveItemIndex<'tabs'>`), which
577
+ * links the prop to an array data prop for the `displayGroups.arrayItems` manifest
578
+ * block. Returns the target array prop name from the string literal type argument.
579
+ * Returns `undefined` for any other declaration, or when the type argument is missing
580
+ * or not a string literal.
581
+ */
582
+ function detectActiveItemIndex(
583
+ prop: ts.Symbol,
584
+ sourceFile: ts.SourceFile,
585
+ checker: ts.TypeChecker,
586
+ ): ActiveItemIndexInfo | undefined {
587
+ const declaration = prop.getDeclarations()?.[0]
588
+ if (!declaration) return undefined
589
+
590
+ if (
591
+ !(ts.isPropertySignature(declaration) || ts.isPropertyDeclaration(declaration) || ts.isParameter(declaration)) ||
592
+ !declaration.type ||
593
+ !ts.isTypeReferenceNode(declaration.type)
594
+ ) {
595
+ return undefined
596
+ }
597
+
598
+ const typeName = declaration.type.typeName
599
+ const identifier = ts.isIdentifier(typeName) ? typeName : typeName.right
600
+ if (identifier.text !== 'ActiveItemIndex') return undefined
601
+ if (!isActiveItemIndexAliasFromUtils(identifier, checker)) return undefined
602
+
603
+ // Require an explicit string literal type argument — bare ActiveItemIndex (no arg)
604
+ // compiles but gives ZeroConfig no array name to link to.
605
+ const typeArgs = declaration.type.typeArguments
606
+ if (!typeArgs || typeArgs.length !== 1) return undefined
607
+
608
+ const typeArgNode = typeArgs[0]
609
+ if (!ts.isLiteralTypeNode(typeArgNode) || !ts.isStringLiteral(typeArgNode.literal)) return undefined
610
+
611
+ return { targetArrayProp: typeArgNode.literal.text }
612
+ }
613
+
614
+ /**
615
+ * Confirms an identifier referencing `ActiveItemIndex` resolves to the marker type
616
+ * alias exported by `@wix/react-component-utils`, rather than an unrelated local
617
+ * type that happens to be named `ActiveItemIndex`.
618
+ */
619
+ function isActiveItemIndexAliasFromUtils(identifier: ts.Identifier, checker: ts.TypeChecker): boolean {
620
+ let symbol = checker.getSymbolAtLocation(identifier)
621
+ if (!symbol) return false
622
+ if ((symbol.getFlags() & ts.SymbolFlags.Alias) !== 0) {
623
+ symbol = checker.getAliasedSymbol(symbol)
624
+ }
625
+
626
+ const declaration = symbol.getDeclarations()?.[0]
627
+ if (!declaration || !ts.isTypeAliasDeclaration(declaration)) return false
628
+ if (declaration.name.text !== 'ActiveItemIndex') return false
629
+
630
+ return declaration.getSourceFile().fileName.includes('react-component-utils')
631
+ }
632
+
561
633
  function getDeclaredTypeInfo(
562
634
  prop: ts.Symbol,
563
635
  sourceFile: ts.SourceFile,
@@ -59,6 +59,11 @@ export interface PropInfo {
59
59
  // state. `type` and `resolvedType` reflect the unwrapped inner type, so the prop
60
60
  // still behaves as a normal data prop everywhere else.
61
61
  isStateTrigger?: boolean
62
+ // When the prop is wrapped in `ActiveItemIndex<'arrayPropName'>` from
63
+ // @wix/react-component-utils, carries the target array prop name. `type` and
64
+ // `resolvedType` reflect the unwrapped `number` type. Used by the display-groups
65
+ // builder to auto-generate the `displayGroups.arrayItems` manifest block.
66
+ activeItemIndexTarget?: string
62
67
  }
63
68
 
64
69
  export interface ComponentInfo {