@open-mercato/ui 0.6.8-develop.6930.1.1e5976efc3 → 0.6.8-develop.6931.1.4a1e1c786b
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.
- package/dist/backend/injection/useRegisteredComponent.js +48 -36
- package/dist/backend/injection/useRegisteredComponent.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/injection/__tests__/useRegisteredComponent.remount.test.tsx +96 -0
- package/src/backend/injection/useRegisteredComponent.tsx +85 -38
|
@@ -21,45 +21,58 @@ class ReplacementErrorBoundary extends React.Component {
|
|
|
21
21
|
return this.props.children;
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
|
-
function
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
if (!original) {
|
|
31
|
-
if (process.env.NODE_ENV !== "production" && !fallback) {
|
|
32
|
-
logger.warn("Component is not registered", { componentId });
|
|
33
|
-
}
|
|
34
|
-
const Missing = () => null;
|
|
35
|
-
return Missing;
|
|
24
|
+
function resolveComponent(componentId, fallback, userFeatures) {
|
|
25
|
+
const entry = getComponentEntry(componentId);
|
|
26
|
+
const original = entry?.component ?? fallback ?? null;
|
|
27
|
+
if (!original) {
|
|
28
|
+
if (process.env.NODE_ENV !== "production" && !fallback) {
|
|
29
|
+
logger.warn("Component is not registered", { componentId });
|
|
36
30
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
if ("propsTransform" in override) transforms.push(override.propsTransform);
|
|
31
|
+
return { original: null, wrapped: null, transforms: [], replacementOverride: null, replacementModule: "unknown" };
|
|
32
|
+
}
|
|
33
|
+
const overrides = getComponentOverrides(componentId, userFeatures);
|
|
34
|
+
const replacementOverrides = overrides.filter((override) => "replacement" in override);
|
|
35
|
+
if (process.env.NODE_ENV !== "production" && replacementOverrides.length > 1) {
|
|
36
|
+
logger.warn("Multiple replacements registered; highest-priority replacement is applied", { componentId });
|
|
37
|
+
}
|
|
38
|
+
let replacement = null;
|
|
39
|
+
let replacementOverride = null;
|
|
40
|
+
const wrappers = [];
|
|
41
|
+
const transforms = [];
|
|
42
|
+
for (const override of overrides) {
|
|
43
|
+
if ("replacement" in override) {
|
|
44
|
+
replacement = override.replacement;
|
|
45
|
+
replacementOverride = override;
|
|
53
46
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
47
|
+
if ("wrapper" in override) wrappers.push(override.wrapper);
|
|
48
|
+
if ("propsTransform" in override) transforms.push(override.propsTransform);
|
|
49
|
+
}
|
|
50
|
+
const base = replacement ?? original;
|
|
51
|
+
const wrapped = wrappers.reduce((acc, wrapper) => wrapper(acc), base);
|
|
52
|
+
return {
|
|
53
|
+
original,
|
|
54
|
+
wrapped,
|
|
55
|
+
transforms,
|
|
56
|
+
replacementOverride,
|
|
57
|
+
replacementModule: replacementOverride?.metadata?.module ?? "unknown"
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function useRegisteredComponent(componentId, fallback) {
|
|
61
|
+
return React.useMemo(() => {
|
|
62
|
+
const Registered = (props) => {
|
|
63
|
+
const userFeatures = useOverrideUserFeatures();
|
|
64
|
+
const overrideRevision = useOverrideRegistryRevision();
|
|
65
|
+
const { original, wrapped, transforms, replacementOverride, replacementModule } = React.useMemo(
|
|
66
|
+
() => resolveComponent(componentId, fallback, userFeatures),
|
|
67
|
+
[overrideRevision, userFeatures]
|
|
68
|
+
);
|
|
69
|
+
if (!original || !wrapped) return null;
|
|
57
70
|
const transformed = transforms.reduce((current, transform) => transform(current), props);
|
|
58
71
|
const Fallback = React.createElement(original, transformed);
|
|
59
72
|
if (process.env.NODE_ENV !== "production" && replacementOverride && "replacement" in replacementOverride) {
|
|
60
73
|
const validation = replacementOverride.propsSchema.safeParse(transformed);
|
|
61
74
|
if (!validation.success) {
|
|
62
|
-
logger.error("Props schema validation failed for replacement", { componentId, module:
|
|
75
|
+
logger.error("Props schema validation failed for replacement", { componentId, module: replacementModule, issues: validation.error.format() });
|
|
63
76
|
return Fallback;
|
|
64
77
|
}
|
|
65
78
|
}
|
|
@@ -68,16 +81,15 @@ function useRegisteredComponent(componentId, fallback) {
|
|
|
68
81
|
{
|
|
69
82
|
fallback: Fallback,
|
|
70
83
|
onError: (error) => {
|
|
71
|
-
const replacementModule = overrides.find((override) => "replacement" in override)?.metadata?.module ?? "unknown";
|
|
72
84
|
logger.error("Component replacement failed", { componentId, module: replacementModule, err: error });
|
|
73
85
|
},
|
|
74
86
|
children: React.createElement(wrapped, transformed)
|
|
75
87
|
}
|
|
76
88
|
);
|
|
77
89
|
};
|
|
78
|
-
|
|
79
|
-
return
|
|
80
|
-
}, [componentId, fallback
|
|
90
|
+
Registered.displayName = `RegisteredComponent(${componentId})`;
|
|
91
|
+
return Registered;
|
|
92
|
+
}, [componentId, fallback]);
|
|
81
93
|
}
|
|
82
94
|
var useRegisteredComponent_default = useRegisteredComponent;
|
|
83
95
|
export {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/backend/injection/useRegisteredComponent.tsx"],
|
|
4
|
-
"sourcesContent": ["'use client'\n\nimport * as React from 'react'\nimport type { ComponentType } from 'react'\nimport { getComponentEntry, getComponentOverrides } from '@open-mercato/shared/modules/widgets/component-registry'\nimport { useOverrideRegistryRevision, useOverrideUserFeatures } from './ComponentOverrideProvider'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('ui').child({ component: 'useRegisteredComponent' })\n\nclass ReplacementErrorBoundary extends React.Component<\n { fallback: React.ReactNode; onError: (error: unknown) => void; children: React.ReactNode },\n { hasError: boolean }\n> {\n constructor(props: { fallback: React.ReactNode; onError: (error: unknown) => void; children: React.ReactNode }) {\n super(props)\n this.state = { hasError: false }\n }\n\n static getDerivedStateFromError(): { hasError: boolean } {\n return { hasError: true }\n }\n\n componentDidCatch(error: unknown): void {\n this.props.onError(error)\n }\n\n render() {\n if (this.state.hasError) return this.props.fallback\n return this.props.children\n }\n}\n\
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["'use client'\n\nimport * as React from 'react'\nimport type { ComponentType } from 'react'\nimport type { ComponentOverride } from '@open-mercato/shared/modules/widgets/component-registry'\nimport { getComponentEntry, getComponentOverrides } from '@open-mercato/shared/modules/widgets/component-registry'\nimport { useOverrideRegistryRevision, useOverrideUserFeatures } from './ComponentOverrideProvider'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('ui').child({ component: 'useRegisteredComponent' })\n\nclass ReplacementErrorBoundary extends React.Component<\n { fallback: React.ReactNode; onError: (error: unknown) => void; children: React.ReactNode },\n { hasError: boolean }\n> {\n constructor(props: { fallback: React.ReactNode; onError: (error: unknown) => void; children: React.ReactNode }) {\n super(props)\n this.state = { hasError: false }\n }\n\n static getDerivedStateFromError(): { hasError: boolean } {\n return { hasError: true }\n }\n\n componentDidCatch(error: unknown): void {\n this.props.onError(error)\n }\n\n render() {\n if (this.state.hasError) return this.props.fallback\n return this.props.children\n }\n}\n\ntype Resolution<TProps> = {\n original: ComponentType<TProps> | null\n wrapped: ComponentType<TProps> | null\n transforms: Array<(props: TProps) => TProps>\n replacementOverride: ComponentOverride | null\n replacementModule: string\n}\n\nfunction resolveComponent<TProps>(\n componentId: string,\n fallback: ComponentType<TProps> | undefined,\n userFeatures: readonly string[],\n): Resolution<TProps> {\n const entry = getComponentEntry(componentId)\n const original = (entry?.component as ComponentType<TProps> | undefined) ?? fallback ?? null\n if (!original) {\n if (process.env.NODE_ENV !== 'production' && !fallback) {\n logger.warn('Component is not registered', { componentId })\n }\n return { original: null, wrapped: null, transforms: [], replacementOverride: null, replacementModule: 'unknown' }\n }\n\n const overrides = getComponentOverrides(componentId, userFeatures)\n const replacementOverrides = overrides.filter((override) => 'replacement' in override)\n if (process.env.NODE_ENV !== 'production' && replacementOverrides.length > 1) {\n logger.warn('Multiple replacements registered; highest-priority replacement is applied', { componentId })\n }\n\n let replacement: ComponentType<TProps> | null = null\n let replacementOverride: ComponentOverride | null = null\n const wrappers: Array<(Original: ComponentType<TProps>) => ComponentType<TProps>> = []\n const transforms: Array<(props: TProps) => TProps> = []\n\n for (const override of overrides) {\n if ('replacement' in override) {\n replacement = override.replacement as ComponentType<TProps>\n replacementOverride = override\n }\n if ('wrapper' in override) wrappers.push(override.wrapper as (Original: ComponentType<TProps>) => ComponentType<TProps>)\n if ('propsTransform' in override) transforms.push(override.propsTransform as (props: TProps) => TProps)\n }\n\n const base = replacement ?? original\n const wrapped = wrappers.reduce<ComponentType<TProps>>((acc, wrapper) => wrapper(acc), base)\n\n return {\n original,\n wrapped,\n transforms,\n replacementOverride,\n replacementModule: replacementOverride?.metadata?.module ?? 'unknown',\n }\n}\n\n/**\n * The returned component's identity must depend only on `componentId` and\n * `fallback` \u2014 never on the override registry.\n *\n * Overrides arrive asynchronously: `ComponentOverridesBootstrap` dynamically\n * imports the generated override module and hands the provider a fresh array,\n * which bumps the registry revision some time after first paint. Resolving the\n * component in a `useMemo` keyed on that revision handed callers a brand-new\n * function on every bump, so React saw a different element type at that\n * position and unmounted the whole subtree \u2014 discarding its DOM and state. On\n * the login form that threw away credentials the user had already typed\n * (#5037), and the same hazard applied to every host of a registered section.\n *\n * Resolution therefore happens *inside* a stable component. When the revision\n * carries no override for this id, `wrapped` keeps its previous identity and\n * React reconciles in place; only a genuine replacement or wrapper swaps the\n * rendered type, where a remount is the correct behaviour.\n */\nexport function useRegisteredComponent<TProps>(\n componentId: string,\n fallback?: ComponentType<TProps>,\n): ComponentType<TProps> {\n return React.useMemo(() => {\n const Registered = (props: TProps) => {\n const userFeatures = useOverrideUserFeatures()\n const overrideRevision = useOverrideRegistryRevision()\n const { original, wrapped, transforms, replacementOverride, replacementModule } = React.useMemo(\n () => resolveComponent<TProps>(componentId, fallback, userFeatures),\n [overrideRevision, userFeatures],\n )\n\n if (!original || !wrapped) return null\n\n const transformed = transforms.reduce((current, transform) => transform(current), props)\n const Fallback = React.createElement(original as React.ComponentType<Record<string, unknown>>, transformed as Record<string, unknown>)\n if (\n process.env.NODE_ENV !== 'production'\n && replacementOverride\n && 'replacement' in replacementOverride\n ) {\n const validation = replacementOverride.propsSchema.safeParse(transformed)\n if (!validation.success) {\n logger.error('Props schema validation failed for replacement', { componentId, module: replacementModule, issues: validation.error.format() })\n return Fallback\n }\n }\n return (\n <ReplacementErrorBoundary\n fallback={Fallback}\n onError={(error) => {\n logger.error('Component replacement failed', { componentId, module: replacementModule, err: error })\n }}\n >\n {React.createElement(wrapped as React.ComponentType<Record<string, unknown>>, transformed as Record<string, unknown>)}\n </ReplacementErrorBoundary>\n )\n }\n\n Registered.displayName = `RegisteredComponent(${componentId})`\n return Registered\n }, [componentId, fallback])\n}\n\nexport default useRegisteredComponent\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/ui",
|
|
3
|
-
"version": "0.6.8-develop.
|
|
3
|
+
"version": "0.6.8-develop.6931.1.4a1e1c786b",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -155,14 +155,14 @@
|
|
|
155
155
|
"remark-gfm": "^4.0.1"
|
|
156
156
|
},
|
|
157
157
|
"peerDependencies": {
|
|
158
|
-
"@open-mercato/shared": "0.6.8-develop.
|
|
158
|
+
"@open-mercato/shared": "0.6.8-develop.6931.1.4a1e1c786b",
|
|
159
159
|
"react": ">=18.0.0",
|
|
160
160
|
"react-dom": ">=18.0.0",
|
|
161
161
|
"react-is": ">=18.0.0"
|
|
162
162
|
},
|
|
163
163
|
"devDependencies": {
|
|
164
164
|
"@figma/code-connect": "^1.3.4",
|
|
165
|
-
"@open-mercato/shared": "0.6.8-develop.
|
|
165
|
+
"@open-mercato/shared": "0.6.8-develop.6931.1.4a1e1c786b",
|
|
166
166
|
"@testing-library/dom": "^10.4.1",
|
|
167
167
|
"@testing-library/jest-dom": "^7.0.0",
|
|
168
168
|
"@testing-library/react": "^16.3.1",
|
|
@@ -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
|
-
|
|
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
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
101
|
-
return
|
|
102
|
-
}, [componentId, fallback
|
|
147
|
+
Registered.displayName = `RegisteredComponent(${componentId})`
|
|
148
|
+
return Registered
|
|
149
|
+
}, [componentId, fallback])
|
|
103
150
|
}
|
|
104
151
|
|
|
105
152
|
export default useRegisteredComponent
|