@open-mercato/ui 0.6.8-develop.6930.1.1e5976efc3 → 0.6.8-develop.6940.1.177ea30c6e

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,96 @@
1
+ /** @jest-environment jsdom */
2
+
3
+ import * as React from 'react'
4
+ import { fireEvent, render } from '@testing-library/react'
5
+ import type { ComponentOverride } from '@open-mercato/shared/modules/widgets/component-registry'
6
+ import { ComponentOverrideProvider } from '../ComponentOverrideProvider'
7
+ import { useRegisteredComponent } from '../useRegisteredComponent'
8
+
9
+ const COMPONENT_ID = 'section:test.host.form'
10
+
11
+ function Section({ children }: { children: React.ReactNode }) {
12
+ return <>{children}</>
13
+ }
14
+
15
+ function Host() {
16
+ const Resolved = useRegisteredComponent<{ children: React.ReactNode }>(COMPONENT_ID, Section)
17
+ return (
18
+ <Resolved>
19
+ <form>
20
+ <input id="email" name="email" defaultValue="" />
21
+ </form>
22
+ </Resolved>
23
+ )
24
+ }
25
+
26
+ /**
27
+ * `overrides` arrives as a brand-new array once the async override bundle
28
+ * resolves, exactly the way ComponentOverridesBootstrap hands it over after its
29
+ * dynamic import settles.
30
+ */
31
+ function App({ overrides }: { overrides: ComponentOverride[] }) {
32
+ return (
33
+ <ComponentOverrideProvider overrides={overrides}>
34
+ <Host />
35
+ </ComponentOverrideProvider>
36
+ )
37
+ }
38
+
39
+ function readEmail(): HTMLInputElement {
40
+ return document.querySelector('#email') as HTMLInputElement
41
+ }
42
+
43
+ function typeEmail(value: string): HTMLInputElement {
44
+ const email = readEmail()
45
+ fireEvent.change(email, { target: { value } })
46
+ expect(email.value).toBe(value)
47
+ return email
48
+ }
49
+
50
+ describe('useRegisteredComponent — a late override registry must not discard host state', () => {
51
+ it('keeps typed input when the override bundle resolves with no override for this id', () => {
52
+ const { rerender } = render(<App overrides={[]} />)
53
+ const email = typeEmail('admin@acme.com')
54
+
55
+ // Same (empty) override set, fresh array identity — the registry revision bumps.
56
+ rerender(<App overrides={[]} />)
57
+
58
+ expect(readEmail()).toBe(email)
59
+ expect(readEmail().value).toBe('admin@acme.com')
60
+ })
61
+
62
+ it('keeps typed input when unrelated overrides register', () => {
63
+ const { rerender } = render(<App overrides={[]} />)
64
+ const email = typeEmail('admin@acme.com')
65
+
66
+ const unrelated: ComponentOverride[] = [{
67
+ target: { componentId: 'section:some.other.component' },
68
+ priority: 0,
69
+ metadata: { module: 'other' },
70
+ wrapper: (Original) => Original,
71
+ }]
72
+ rerender(<App overrides={unrelated} />)
73
+
74
+ expect(readEmail()).toBe(email)
75
+ expect(readEmail().value).toBe('admin@acme.com')
76
+ })
77
+
78
+ it('still applies a wrapper registered for this id', () => {
79
+ const withBanner: ComponentOverride[] = [{
80
+ target: { componentId: COMPONENT_ID },
81
+ priority: 0,
82
+ metadata: { module: 'other' },
83
+ wrapper: (Original) => {
84
+ const Wrapped = (props: Record<string, unknown>) => (
85
+ <div data-testid="wrapper-banner">{React.createElement(Original as React.ComponentType<Record<string, unknown>>, props)}</div>
86
+ )
87
+ return Wrapped as typeof Original
88
+ },
89
+ }]
90
+
91
+ const { getByTestId } = render(<App overrides={withBanner} />)
92
+
93
+ expect(getByTestId('wrapper-banner')).toBeTruthy()
94
+ expect(readEmail()).toBeTruthy()
95
+ })
96
+ })
@@ -2,6 +2,7 @@
2
2
 
3
3
  import * as React from 'react'
4
4
  import type { ComponentType } from 'react'
5
+ import type { ComponentOverride } from '@open-mercato/shared/modules/widgets/component-registry'
5
6
  import { getComponentEntry, getComponentOverrides } from '@open-mercato/shared/modules/widgets/component-registry'
6
7
  import { useOverrideRegistryRevision, useOverrideUserFeatures } from './ComponentOverrideProvider'
7
8
  import { createLogger } from '@open-mercato/shared/lib/logger'
@@ -31,46 +32,93 @@ class ReplacementErrorBoundary extends React.Component<
31
32
  }
32
33
  }
33
34
 
34
- export function useRegisteredComponent<TProps>(
35
+ type Resolution<TProps> = {
36
+ original: ComponentType<TProps> | null
37
+ wrapped: ComponentType<TProps> | null
38
+ transforms: Array<(props: TProps) => TProps>
39
+ replacementOverride: ComponentOverride | null
40
+ replacementModule: string
41
+ }
42
+
43
+ function resolveComponent<TProps>(
35
44
  componentId: string,
36
- fallback?: ComponentType<TProps>,
37
- ): ComponentType<TProps> {
38
- const userFeatures = useOverrideUserFeatures()
39
- const overrideRevision = useOverrideRegistryRevision()
40
- return React.useMemo(() => {
41
- const entry = getComponentEntry(componentId)
42
- const original = (entry?.component as ComponentType<TProps> | undefined) ?? fallback ?? null
43
- if (!original) {
44
- if (process.env.NODE_ENV !== 'production' && !fallback) {
45
- logger.warn('Component is not registered', { componentId })
46
- }
47
- const Missing = () => null
48
- return Missing as ComponentType<TProps>
49
- }
50
- const overrides = getComponentOverrides(componentId, userFeatures)
51
- const replacementOverrides = overrides.filter((override) => 'replacement' in override)
52
- if (process.env.NODE_ENV !== 'production' && replacementOverrides.length > 1) {
53
- logger.warn('Multiple replacements registered; highest-priority replacement is applied', { componentId })
45
+ fallback: ComponentType<TProps> | undefined,
46
+ userFeatures: readonly string[],
47
+ ): Resolution<TProps> {
48
+ const entry = getComponentEntry(componentId)
49
+ const original = (entry?.component as ComponentType<TProps> | undefined) ?? fallback ?? null
50
+ if (!original) {
51
+ if (process.env.NODE_ENV !== 'production' && !fallback) {
52
+ logger.warn('Component is not registered', { componentId })
54
53
  }
54
+ return { original: null, wrapped: null, transforms: [], replacementOverride: null, replacementModule: 'unknown' }
55
+ }
55
56
 
56
- let replacement: ComponentType<TProps> | null = null
57
- let replacementOverride: (typeof overrides)[number] | null = null
58
- const wrappers: Array<(Original: ComponentType<TProps>) => ComponentType<TProps>> = []
59
- const transforms: Array<(props: TProps) => TProps> = []
57
+ const overrides = getComponentOverrides(componentId, userFeatures)
58
+ const replacementOverrides = overrides.filter((override) => 'replacement' in override)
59
+ if (process.env.NODE_ENV !== 'production' && replacementOverrides.length > 1) {
60
+ logger.warn('Multiple replacements registered; highest-priority replacement is applied', { componentId })
61
+ }
60
62
 
61
- for (const override of overrides) {
62
- if ('replacement' in override) {
63
- replacement = override.replacement as ComponentType<TProps>
64
- replacementOverride = override
65
- }
66
- if ('wrapper' in override) wrappers.push(override.wrapper as (Original: ComponentType<TProps>) => ComponentType<TProps>)
67
- if ('propsTransform' in override) transforms.push(override.propsTransform as (props: TProps) => TProps)
63
+ let replacement: ComponentType<TProps> | null = null
64
+ let replacementOverride: ComponentOverride | null = null
65
+ const wrappers: Array<(Original: ComponentType<TProps>) => ComponentType<TProps>> = []
66
+ const transforms: Array<(props: TProps) => TProps> = []
67
+
68
+ for (const override of overrides) {
69
+ if ('replacement' in override) {
70
+ replacement = override.replacement as ComponentType<TProps>
71
+ replacementOverride = override
68
72
  }
73
+ if ('wrapper' in override) wrappers.push(override.wrapper as (Original: ComponentType<TProps>) => ComponentType<TProps>)
74
+ if ('propsTransform' in override) transforms.push(override.propsTransform as (props: TProps) => TProps)
75
+ }
76
+
77
+ const base = replacement ?? original
78
+ const wrapped = wrappers.reduce<ComponentType<TProps>>((acc, wrapper) => wrapper(acc), base)
79
+
80
+ return {
81
+ original,
82
+ wrapped,
83
+ transforms,
84
+ replacementOverride,
85
+ replacementModule: replacementOverride?.metadata?.module ?? 'unknown',
86
+ }
87
+ }
88
+
89
+ /**
90
+ * The returned component's identity must depend only on `componentId` and
91
+ * `fallback` — never on the override registry.
92
+ *
93
+ * Overrides arrive asynchronously: `ComponentOverridesBootstrap` dynamically
94
+ * imports the generated override module and hands the provider a fresh array,
95
+ * which bumps the registry revision some time after first paint. Resolving the
96
+ * component in a `useMemo` keyed on that revision handed callers a brand-new
97
+ * function on every bump, so React saw a different element type at that
98
+ * position and unmounted the whole subtree — discarding its DOM and state. On
99
+ * the login form that threw away credentials the user had already typed
100
+ * (#5037), and the same hazard applied to every host of a registered section.
101
+ *
102
+ * Resolution therefore happens *inside* a stable component. When the revision
103
+ * carries no override for this id, `wrapped` keeps its previous identity and
104
+ * React reconciles in place; only a genuine replacement or wrapper swaps the
105
+ * rendered type, where a remount is the correct behaviour.
106
+ */
107
+ export function useRegisteredComponent<TProps>(
108
+ componentId: string,
109
+ fallback?: ComponentType<TProps>,
110
+ ): ComponentType<TProps> {
111
+ return React.useMemo(() => {
112
+ const Registered = (props: TProps) => {
113
+ const userFeatures = useOverrideUserFeatures()
114
+ const overrideRevision = useOverrideRegistryRevision()
115
+ const { original, wrapped, transforms, replacementOverride, replacementModule } = React.useMemo(
116
+ () => resolveComponent<TProps>(componentId, fallback, userFeatures),
117
+ [overrideRevision, userFeatures],
118
+ )
69
119
 
70
- const base = replacement ?? original
71
- const wrapped = wrappers.reduce<ComponentType<TProps>>((acc, wrapper) => wrapper(acc), base)
120
+ if (!original || !wrapped) return null
72
121
 
73
- const Resolved = (props: TProps) => {
74
122
  const transformed = transforms.reduce((current, transform) => transform(current), props)
75
123
  const Fallback = React.createElement(original as React.ComponentType<Record<string, unknown>>, transformed as Record<string, unknown>)
76
124
  if (
@@ -80,7 +128,7 @@ export function useRegisteredComponent<TProps>(
80
128
  ) {
81
129
  const validation = replacementOverride.propsSchema.safeParse(transformed)
82
130
  if (!validation.success) {
83
- logger.error('Props schema validation failed for replacement', { componentId, module: replacementOverride.metadata?.module ?? 'unknown', issues: validation.error.format() })
131
+ logger.error('Props schema validation failed for replacement', { componentId, module: replacementModule, issues: validation.error.format() })
84
132
  return Fallback
85
133
  }
86
134
  }
@@ -88,7 +136,6 @@ export function useRegisteredComponent<TProps>(
88
136
  <ReplacementErrorBoundary
89
137
  fallback={Fallback}
90
138
  onError={(error) => {
91
- const replacementModule = overrides.find((override) => 'replacement' in override)?.metadata?.module ?? 'unknown'
92
139
  logger.error('Component replacement failed', { componentId, module: replacementModule, err: error })
93
140
  }}
94
141
  >
@@ -97,9 +144,9 @@ export function useRegisteredComponent<TProps>(
97
144
  )
98
145
  }
99
146
 
100
- Resolved.displayName = `RegisteredComponent(${componentId})`
101
- return Resolved
102
- }, [componentId, fallback, overrideRevision, userFeatures])
147
+ Registered.displayName = `RegisteredComponent(${componentId})`
148
+ return Registered
149
+ }, [componentId, fallback])
103
150
  }
104
151
 
105
152
  export default useRegisteredComponent