@open-mercato/ui 0.6.8-develop.7059.1.f45d1ae6a1 → 0.6.8-develop.7063.1.664341cf65
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 +24 -6
- package/dist/backend/injection/useRegisteredComponent.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/__tests__/useRegisteredComponent.identity.test.tsx +305 -0
- package/src/backend/injection/useRegisteredComponent.tsx +49 -8
|
@@ -21,6 +21,19 @@ class ReplacementErrorBoundary extends React.Component {
|
|
|
21
21
|
return this.props.children;
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
|
+
const composedWrappers = /* @__PURE__ */ new WeakMap();
|
|
25
|
+
function applyWrapper(wrapper, Base) {
|
|
26
|
+
let byBase = composedWrappers.get(wrapper);
|
|
27
|
+
if (!byBase) {
|
|
28
|
+
byBase = /* @__PURE__ */ new WeakMap();
|
|
29
|
+
composedWrappers.set(wrapper, byBase);
|
|
30
|
+
}
|
|
31
|
+
const cached = byBase.get(Base);
|
|
32
|
+
if (cached) return cached;
|
|
33
|
+
const composed = wrapper(Base);
|
|
34
|
+
byBase.set(Base, composed);
|
|
35
|
+
return composed;
|
|
36
|
+
}
|
|
24
37
|
function resolveComponent(componentId, fallback, userFeatures) {
|
|
25
38
|
const entry = getComponentEntry(componentId);
|
|
26
39
|
const original = entry?.component ?? fallback ?? null;
|
|
@@ -48,7 +61,7 @@ function resolveComponent(componentId, fallback, userFeatures) {
|
|
|
48
61
|
if ("propsTransform" in override) transforms.push(override.propsTransform);
|
|
49
62
|
}
|
|
50
63
|
const base = replacement ?? original;
|
|
51
|
-
const wrapped = wrappers.reduce((acc, wrapper) => wrapper
|
|
64
|
+
const wrapped = wrappers.reduce((acc, wrapper) => applyWrapper(wrapper, acc), base);
|
|
52
65
|
return {
|
|
53
66
|
original,
|
|
54
67
|
wrapped,
|
|
@@ -58,13 +71,17 @@ function resolveComponent(componentId, fallback, userFeatures) {
|
|
|
58
71
|
};
|
|
59
72
|
}
|
|
60
73
|
function useRegisteredComponent(componentId, fallback) {
|
|
61
|
-
|
|
74
|
+
const fallbackRef = React.useRef(fallback);
|
|
75
|
+
fallbackRef.current = fallback;
|
|
76
|
+
const registered = React.useRef(null);
|
|
77
|
+
if (!registered.current || registered.current.componentId !== componentId) {
|
|
62
78
|
const Registered = (props) => {
|
|
63
79
|
const userFeatures = useOverrideUserFeatures();
|
|
64
80
|
const overrideRevision = useOverrideRegistryRevision();
|
|
81
|
+
const currentFallback = fallbackRef.current;
|
|
65
82
|
const { original, wrapped, transforms, replacementOverride, replacementModule } = React.useMemo(
|
|
66
|
-
() => resolveComponent(componentId,
|
|
67
|
-
[overrideRevision, userFeatures]
|
|
83
|
+
() => resolveComponent(componentId, currentFallback, userFeatures),
|
|
84
|
+
[currentFallback, overrideRevision, userFeatures]
|
|
68
85
|
);
|
|
69
86
|
if (!original || !wrapped) return null;
|
|
70
87
|
const transformed = transforms.reduce((current, transform) => transform(current), props);
|
|
@@ -88,8 +105,9 @@ function useRegisteredComponent(componentId, fallback) {
|
|
|
88
105
|
);
|
|
89
106
|
};
|
|
90
107
|
Registered.displayName = `RegisteredComponent(${componentId})`;
|
|
91
|
-
|
|
92
|
-
}
|
|
108
|
+
registered.current = { componentId, Component: Registered };
|
|
109
|
+
}
|
|
110
|
+
return registered.current.Component;
|
|
93
111
|
}
|
|
94
112
|
var useRegisteredComponent_default = useRegisteredComponent;
|
|
95
113
|
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 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
|
|
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\n/**\n * Calling a wrapper override returns a fresh component every time, which would\n * reintroduce the identity churn this hook exists to avoid. Memoizing per\n * (wrapper, wrapped component) pair keeps the composed component referentially\n * stable for as long as both inputs are.\n */\nconst composedWrappers = new WeakMap<object, WeakMap<object, unknown>>()\n\nfunction applyWrapper<TProps>(\n wrapper: (Original: ComponentType<TProps>) => ComponentType<TProps>,\n Base: ComponentType<TProps>,\n): ComponentType<TProps> {\n let byBase = composedWrappers.get(wrapper)\n if (!byBase) {\n byBase = new WeakMap<object, unknown>()\n composedWrappers.set(wrapper, byBase)\n }\n const cached = byBase.get(Base)\n if (cached) return cached as ComponentType<TProps>\n const composed = wrapper(Base)\n byBase.set(Base, composed)\n return composed\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) => applyWrapper(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` \u2014 never\n * on the override registry, and never on the `fallback` a caller happens to\n * pass on this render.\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 *\n * The identity is held in a ref rather than a `useMemo`, because `useMemo` is a\n * performance hint React is free to discard, and identity here is a correctness\n * requirement rather than an optimisation. `fallback` is read through a ref for\n * the same reason: a host that builds its fallback inline would otherwise swap\n * the component this hook hands back on every render. That still cannot save a\n * subtree rendered *through* such a fallback \u2014 the fallback itself is then the\n * element type, and only the host can stabilise it \u2014 but it keeps the churn\n * from spreading to hosts whose id does resolve to a registered component.\n */\nexport function useRegisteredComponent<TProps>(\n componentId: string,\n fallback?: ComponentType<TProps>,\n): ComponentType<TProps> {\n const fallbackRef = React.useRef<ComponentType<TProps> | undefined>(fallback)\n fallbackRef.current = fallback\n\n const registered = React.useRef<{ componentId: string; Component: ComponentType<TProps> } | null>(null)\n if (!registered.current || registered.current.componentId !== componentId) {\n const Registered = (props: TProps) => {\n const userFeatures = useOverrideUserFeatures()\n const overrideRevision = useOverrideRegistryRevision()\n const currentFallback = fallbackRef.current\n const { original, wrapped, transforms, replacementOverride, replacementModule } = React.useMemo(\n () => resolveComponent<TProps>(componentId, currentFallback, userFeatures),\n [currentFallback, 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 registered.current = { componentId, Component: Registered }\n }\n\n return registered.current.Component\n}\n\nexport default useRegisteredComponent\n"],
|
|
5
|
+
"mappings": ";AA8KQ;AA5KR,YAAY,WAAW;AAGvB,SAAS,mBAAmB,6BAA6B;AACzD,SAAS,6BAA6B,+BAA+B;AACrE,SAAS,oBAAoB;AAE7B,MAAM,SAAS,aAAa,IAAI,EAAE,MAAM,EAAE,WAAW,yBAAyB,CAAC;AAE/E,MAAM,iCAAiC,MAAM,UAG3C;AAAA,EACA,YAAY,OAAoG;AAC9G,UAAM,KAAK;AACX,SAAK,QAAQ,EAAE,UAAU,MAAM;AAAA,EACjC;AAAA,EAEA,OAAO,2BAAkD;AACvD,WAAO,EAAE,UAAU,KAAK;AAAA,EAC1B;AAAA,EAEA,kBAAkB,OAAsB;AACtC,SAAK,MAAM,QAAQ,KAAK;AAAA,EAC1B;AAAA,EAEA,SAAS;AACP,QAAI,KAAK,MAAM,SAAU,QAAO,KAAK,MAAM;AAC3C,WAAO,KAAK,MAAM;AAAA,EACpB;AACF;AAgBA,MAAM,mBAAmB,oBAAI,QAA0C;AAEvE,SAAS,aACP,SACA,MACuB;AACvB,MAAI,SAAS,iBAAiB,IAAI,OAAO;AACzC,MAAI,CAAC,QAAQ;AACX,aAAS,oBAAI,QAAyB;AACtC,qBAAiB,IAAI,SAAS,MAAM;AAAA,EACtC;AACA,QAAM,SAAS,OAAO,IAAI,IAAI;AAC9B,MAAI,OAAQ,QAAO;AACnB,QAAM,WAAW,QAAQ,IAAI;AAC7B,SAAO,IAAI,MAAM,QAAQ;AACzB,SAAO;AACT;AAEA,SAAS,iBACP,aACA,UACA,cACoB;AACpB,QAAM,QAAQ,kBAAkB,WAAW;AAC3C,QAAM,WAAY,OAAO,aAAmD,YAAY;AACxF,MAAI,CAAC,UAAU;AACb,QAAI,QAAQ,IAAI,aAAa,gBAAgB,CAAC,UAAU;AACtD,aAAO,KAAK,+BAA+B,EAAE,YAAY,CAAC;AAAA,IAC5D;AACA,WAAO,EAAE,UAAU,MAAM,SAAS,MAAM,YAAY,CAAC,GAAG,qBAAqB,MAAM,mBAAmB,UAAU;AAAA,EAClH;AAEA,QAAM,YAAY,sBAAsB,aAAa,YAAY;AACjE,QAAM,uBAAuB,UAAU,OAAO,CAAC,aAAa,iBAAiB,QAAQ;AACrF,MAAI,QAAQ,IAAI,aAAa,gBAAgB,qBAAqB,SAAS,GAAG;AAC5E,WAAO,KAAK,6EAA6E,EAAE,YAAY,CAAC;AAAA,EAC1G;AAEA,MAAI,cAA4C;AAChD,MAAI,sBAAgD;AACpD,QAAM,WAA8E,CAAC;AACrF,QAAM,aAA+C,CAAC;AAEtD,aAAW,YAAY,WAAW;AAChC,QAAI,iBAAiB,UAAU;AAC7B,oBAAc,SAAS;AACvB,4BAAsB;AAAA,IACxB;AACA,QAAI,aAAa,SAAU,UAAS,KAAK,SAAS,OAAqE;AACvH,QAAI,oBAAoB,SAAU,YAAW,KAAK,SAAS,cAA2C;AAAA,EACxG;AAEA,QAAM,OAAO,eAAe;AAC5B,QAAM,UAAU,SAAS,OAA8B,CAAC,KAAK,YAAY,aAAa,SAAS,GAAG,GAAG,IAAI;AAEzG,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,mBAAmB,qBAAqB,UAAU,UAAU;AAAA,EAC9D;AACF;AA8BO,SAAS,uBACd,aACA,UACuB;AACvB,QAAM,cAAc,MAAM,OAA0C,QAAQ;AAC5E,cAAY,UAAU;AAEtB,QAAM,aAAa,MAAM,OAAyE,IAAI;AACtG,MAAI,CAAC,WAAW,WAAW,WAAW,QAAQ,gBAAgB,aAAa;AACzE,UAAM,aAAa,CAAC,UAAkB;AACpC,YAAM,eAAe,wBAAwB;AAC7C,YAAM,mBAAmB,4BAA4B;AACrD,YAAM,kBAAkB,YAAY;AACpC,YAAM,EAAE,UAAU,SAAS,YAAY,qBAAqB,kBAAkB,IAAI,MAAM;AAAA,QACtF,MAAM,iBAAyB,aAAa,iBAAiB,YAAY;AAAA,QACzE,CAAC,iBAAiB,kBAAkB,YAAY;AAAA,MAClD;AAEA,UAAI,CAAC,YAAY,CAAC,QAAS,QAAO;AAElC,YAAM,cAAc,WAAW,OAAO,CAAC,SAAS,cAAc,UAAU,OAAO,GAAG,KAAK;AACvF,YAAM,WAAW,MAAM,cAAc,UAA0D,WAAsC;AACrI,UACE,QAAQ,IAAI,aAAa,gBACtB,uBACA,iBAAiB,qBACpB;AACA,cAAM,aAAa,oBAAoB,YAAY,UAAU,WAAW;AACxE,YAAI,CAAC,WAAW,SAAS;AACvB,iBAAO,MAAM,kDAAkD,EAAE,aAAa,QAAQ,mBAAmB,QAAQ,WAAW,MAAM,OAAO,EAAE,CAAC;AAC5I,iBAAO;AAAA,QACT;AAAA,MACF;AACA,aACE;AAAA,QAAC;AAAA;AAAA,UACC,UAAU;AAAA,UACV,SAAS,CAAC,UAAU;AAClB,mBAAO,MAAM,gCAAgC,EAAE,aAAa,QAAQ,mBAAmB,KAAK,MAAM,CAAC;AAAA,UACrG;AAAA,UAEC,gBAAM,cAAc,SAAyD,WAAsC;AAAA;AAAA,MACtH;AAAA,IAEJ;AAEA,eAAW,cAAc,uBAAuB,WAAW;AAC3D,eAAW,UAAU,EAAE,aAAa,WAAW,WAAW;AAAA,EAC5D;AAEA,SAAO,WAAW,QAAQ;AAC5B;AAEA,IAAO,iCAAQ;",
|
|
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.7063.1.664341cf65",
|
|
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.7063.1.664341cf65",
|
|
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.7063.1.664341cf65",
|
|
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,305 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
4
|
+
import { z } from 'zod'
|
|
5
|
+
import {
|
|
6
|
+
registerComponent,
|
|
7
|
+
registerComponentOverrides,
|
|
8
|
+
} from '@open-mercato/shared/modules/widgets/component-registry'
|
|
9
|
+
import type { ComponentOverride } from '@open-mercato/shared/modules/widgets/component-registry'
|
|
10
|
+
import { ComponentOverrideProvider, useOverrideUserFeatures } from '../injection/ComponentOverrideProvider'
|
|
11
|
+
import { useRegisteredComponent } from '../injection/useRegisteredComponent'
|
|
12
|
+
|
|
13
|
+
jest.mock('../utils/apiCall', () => ({
|
|
14
|
+
apiCall: jest.fn(async () => ({ ok: true, result: { granted: ['acme.override'] } })),
|
|
15
|
+
}))
|
|
16
|
+
|
|
17
|
+
type FieldProps = { label?: string }
|
|
18
|
+
|
|
19
|
+
const mounts = { count: 0 }
|
|
20
|
+
|
|
21
|
+
function StatefulField({ label }: FieldProps) {
|
|
22
|
+
React.useEffect(() => {
|
|
23
|
+
mounts.count += 1
|
|
24
|
+
}, [])
|
|
25
|
+
return (
|
|
26
|
+
<label>
|
|
27
|
+
{label ?? 'field'}
|
|
28
|
+
<input data-testid="field" defaultValue="" />
|
|
29
|
+
</label>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function FeaturesProbe() {
|
|
34
|
+
const features = useOverrideUserFeatures()
|
|
35
|
+
return <span data-testid="features">{features.join(',')}</span>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function typeIntoField(value: string) {
|
|
39
|
+
fireEvent.change(screen.getByTestId('field'), { target: { value } })
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('useRegisteredComponent identity', () => {
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
mounts.count = 0
|
|
45
|
+
registerComponentOverrides([])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
registerComponentOverrides([])
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('keeps the subtree mounted when override user features resolve after first paint', async () => {
|
|
53
|
+
const componentId = 'test.identity.features'
|
|
54
|
+
registerComponent({ id: componentId, component: StatefulField, metadata: { module: 'test' } })
|
|
55
|
+
|
|
56
|
+
function Consumer() {
|
|
57
|
+
const Resolved = useRegisteredComponent<FieldProps>(componentId)
|
|
58
|
+
return <Resolved />
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The override targets a different component, so the resolution result for
|
|
62
|
+
// `componentId` is identical before and after the feature grant arrives.
|
|
63
|
+
const overrides: ComponentOverride[] = [
|
|
64
|
+
{
|
|
65
|
+
target: { componentId: 'test.identity.other' },
|
|
66
|
+
priority: 10,
|
|
67
|
+
features: ['acme.override'],
|
|
68
|
+
metadata: { module: 'test' },
|
|
69
|
+
propsTransform: (props: unknown) => props,
|
|
70
|
+
} as ComponentOverride,
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
render(
|
|
74
|
+
<ComponentOverrideProvider overrides={overrides}>
|
|
75
|
+
<FeaturesProbe />
|
|
76
|
+
<Consumer />
|
|
77
|
+
</ComponentOverrideProvider>,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
typeIntoField('admin@acme.com')
|
|
81
|
+
expect(mounts.count).toBe(1)
|
|
82
|
+
|
|
83
|
+
await waitFor(() => expect(screen.getByTestId('features')).toHaveTextContent('acme.override'))
|
|
84
|
+
|
|
85
|
+
expect(mounts.count).toBe(1)
|
|
86
|
+
expect(screen.getByTestId('field')).toHaveValue('admin@acme.com')
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('keeps the subtree mounted when overrides are registered late', async () => {
|
|
90
|
+
const componentId = 'test.identity.late'
|
|
91
|
+
registerComponent({ id: componentId, component: StatefulField, metadata: { module: 'test' } })
|
|
92
|
+
|
|
93
|
+
function Consumer() {
|
|
94
|
+
const Resolved = useRegisteredComponent<FieldProps>(componentId)
|
|
95
|
+
return <Resolved />
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const lateOverrides: ComponentOverride[] = [
|
|
99
|
+
{
|
|
100
|
+
target: { componentId: 'test.identity.late.other' },
|
|
101
|
+
priority: 10,
|
|
102
|
+
metadata: { module: 'test' },
|
|
103
|
+
propsTransform: (props: unknown) => props,
|
|
104
|
+
} as ComponentOverride,
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
const { rerender } = render(
|
|
108
|
+
<ComponentOverrideProvider overrides={[]}>
|
|
109
|
+
<Consumer />
|
|
110
|
+
</ComponentOverrideProvider>,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
typeIntoField('typed before the registry settled')
|
|
114
|
+
expect(mounts.count).toBe(1)
|
|
115
|
+
|
|
116
|
+
await act(async () => {
|
|
117
|
+
rerender(
|
|
118
|
+
<ComponentOverrideProvider overrides={lateOverrides}>
|
|
119
|
+
<Consumer />
|
|
120
|
+
</ComponentOverrideProvider>,
|
|
121
|
+
)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
expect(mounts.count).toBe(1)
|
|
125
|
+
expect(screen.getByTestId('field')).toHaveValue('typed before the registry settled')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('keeps a wrapper-wrapped subtree mounted across override re-registrations', async () => {
|
|
129
|
+
const componentId = 'test.identity.wrapped'
|
|
130
|
+
registerComponent({ id: componentId, component: StatefulField, metadata: { module: 'test' } })
|
|
131
|
+
|
|
132
|
+
const wrapper = (Original: React.ComponentType<FieldProps>) => {
|
|
133
|
+
const Wrapped = (props: FieldProps) => (
|
|
134
|
+
<div data-testid="wrapped">
|
|
135
|
+
<Original {...props} />
|
|
136
|
+
</div>
|
|
137
|
+
)
|
|
138
|
+
Wrapped.displayName = 'Wrapped'
|
|
139
|
+
return Wrapped
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const buildOverrides = (): ComponentOverride[] => [
|
|
143
|
+
{ target: { componentId }, priority: 10, metadata: { module: 'test' }, wrapper } as unknown as ComponentOverride,
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
function Consumer() {
|
|
147
|
+
const Resolved = useRegisteredComponent<FieldProps>(componentId)
|
|
148
|
+
return <Resolved />
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const { rerender } = render(
|
|
152
|
+
<ComponentOverrideProvider overrides={buildOverrides()}>
|
|
153
|
+
<Consumer />
|
|
154
|
+
</ComponentOverrideProvider>,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
expect(screen.getByTestId('wrapped')).toBeInTheDocument()
|
|
158
|
+
typeIntoField('typed inside a wrapped section')
|
|
159
|
+
expect(mounts.count).toBe(1)
|
|
160
|
+
|
|
161
|
+
// A new array with the same wrapper function — what a provider re-render looks like.
|
|
162
|
+
await act(async () => {
|
|
163
|
+
rerender(
|
|
164
|
+
<ComponentOverrideProvider overrides={buildOverrides()}>
|
|
165
|
+
<Consumer />
|
|
166
|
+
</ComponentOverrideProvider>,
|
|
167
|
+
)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
expect(mounts.count).toBe(1)
|
|
171
|
+
expect(screen.getByTestId('field')).toHaveValue('typed inside a wrapped section')
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('composes a wrapper at most once per (wrapper, wrapped component) pair', async () => {
|
|
175
|
+
// The user-visible consequence is covered by the test above; this one pins the
|
|
176
|
+
// cache's own contract, so that dropping the memoization fails loudly and the
|
|
177
|
+
// purity requirement documented on the `wrapper` override member stays executable.
|
|
178
|
+
const componentId = 'test.identity.wrapper-composition'
|
|
179
|
+
const Base = (props: FieldProps) => <StatefulField {...props} />
|
|
180
|
+
Base.displayName = 'Base'
|
|
181
|
+
registerComponent({ id: componentId, component: Base, metadata: { module: 'test' } })
|
|
182
|
+
|
|
183
|
+
let compositions = 0
|
|
184
|
+
const wrapper = (Original: React.ComponentType<FieldProps>) => {
|
|
185
|
+
compositions += 1
|
|
186
|
+
const Wrapped = (props: FieldProps) => (
|
|
187
|
+
<div data-testid="wrapped">
|
|
188
|
+
<Original {...props} />
|
|
189
|
+
</div>
|
|
190
|
+
)
|
|
191
|
+
Wrapped.displayName = 'Wrapped'
|
|
192
|
+
return Wrapped
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const buildOverrides = (): ComponentOverride[] => [
|
|
196
|
+
{ target: { componentId }, priority: 10, metadata: { module: 'test' }, wrapper } as unknown as ComponentOverride,
|
|
197
|
+
]
|
|
198
|
+
|
|
199
|
+
function Consumer() {
|
|
200
|
+
const Resolved = useRegisteredComponent<FieldProps>(componentId)
|
|
201
|
+
return <Resolved />
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const { rerender } = render(
|
|
205
|
+
<ComponentOverrideProvider overrides={buildOverrides()}>
|
|
206
|
+
<Consumer />
|
|
207
|
+
</ComponentOverrideProvider>,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
expect(screen.getByTestId('wrapped')).toBeInTheDocument()
|
|
211
|
+
expect(compositions).toBe(1)
|
|
212
|
+
|
|
213
|
+
for (let pass = 0; pass < 2; pass += 1) {
|
|
214
|
+
await act(async () => {
|
|
215
|
+
rerender(
|
|
216
|
+
<ComponentOverrideProvider overrides={buildOverrides()}>
|
|
217
|
+
<Consumer />
|
|
218
|
+
</ComponentOverrideProvider>,
|
|
219
|
+
)
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
expect(compositions).toBe(1)
|
|
224
|
+
expect(mounts.count).toBe(1)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('still applies a replacement that becomes active after the first render', async () => {
|
|
228
|
+
const componentId = 'test.identity.replacement'
|
|
229
|
+
const Original = () => <span data-testid="rendered">original</span>
|
|
230
|
+
const Replacement = () => <span data-testid="rendered">replacement</span>
|
|
231
|
+
registerComponent({ id: componentId, component: Original, metadata: { module: 'test' } })
|
|
232
|
+
|
|
233
|
+
function Consumer() {
|
|
234
|
+
const Resolved = useRegisteredComponent<FieldProps>(componentId)
|
|
235
|
+
return <Resolved />
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const lateOverrides: ComponentOverride[] = [
|
|
239
|
+
{
|
|
240
|
+
target: { componentId },
|
|
241
|
+
priority: 100,
|
|
242
|
+
metadata: { module: 'test' },
|
|
243
|
+
replacement: Replacement,
|
|
244
|
+
propsSchema: z.object({}).passthrough(),
|
|
245
|
+
} as unknown as ComponentOverride,
|
|
246
|
+
]
|
|
247
|
+
|
|
248
|
+
const { rerender } = render(
|
|
249
|
+
<ComponentOverrideProvider overrides={[]}>
|
|
250
|
+
<Consumer />
|
|
251
|
+
</ComponentOverrideProvider>,
|
|
252
|
+
)
|
|
253
|
+
expect(screen.getByTestId('rendered')).toHaveTextContent('original')
|
|
254
|
+
|
|
255
|
+
await act(async () => {
|
|
256
|
+
rerender(
|
|
257
|
+
<ComponentOverrideProvider overrides={lateOverrides}>
|
|
258
|
+
<Consumer />
|
|
259
|
+
</ComponentOverrideProvider>,
|
|
260
|
+
)
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
expect(screen.getByTestId('rendered')).toHaveTextContent('replacement')
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('cannot save a subtree whose host re-creates the fallback on every render', () => {
|
|
267
|
+
const componentId = 'test.identity.inline-fallback'
|
|
268
|
+
|
|
269
|
+
function Consumer({ label }: { label: string }) {
|
|
270
|
+
// A fallback declared inside the host's render body is a new function on
|
|
271
|
+
// every render, and with no component registered under this id it *is*
|
|
272
|
+
// the component being rendered. The hook keeps its own identity stable,
|
|
273
|
+
// but it cannot make the caller's fallback stable — React sees a new
|
|
274
|
+
// element type below and must remount. Hosts that care about the state
|
|
275
|
+
// under an unregistered id have to hoist the fallback out of render.
|
|
276
|
+
const Fallback = (props: FieldProps) => <StatefulField {...props} />
|
|
277
|
+
const Resolved = useRegisteredComponent<FieldProps>(componentId, Fallback)
|
|
278
|
+
return <Resolved label={label} />
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const { rerender } = render(<Consumer label="first" />)
|
|
282
|
+
typeIntoField('typed against an inline fallback')
|
|
283
|
+
expect(mounts.count).toBe(1)
|
|
284
|
+
|
|
285
|
+
rerender(<Consumer label="second" />)
|
|
286
|
+
|
|
287
|
+
expect(mounts.count).toBe(2)
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it('remounts when the host asks for a different component id', () => {
|
|
291
|
+
function Consumer({ componentId }: { componentId: string }) {
|
|
292
|
+
const Resolved = useRegisteredComponent<FieldProps>(componentId, StatefulField)
|
|
293
|
+
return <Resolved />
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const { rerender } = render(<Consumer componentId="test.identity.provider-a" />)
|
|
297
|
+
typeIntoField('provider a input')
|
|
298
|
+
expect(mounts.count).toBe(1)
|
|
299
|
+
|
|
300
|
+
rerender(<Consumer componentId="test.identity.provider-b" />)
|
|
301
|
+
|
|
302
|
+
expect(mounts.count).toBe(2)
|
|
303
|
+
expect(screen.getByTestId('field')).toHaveValue('')
|
|
304
|
+
})
|
|
305
|
+
})
|
|
@@ -40,6 +40,30 @@ type Resolution<TProps> = {
|
|
|
40
40
|
replacementModule: string
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Calling a wrapper override returns a fresh component every time, which would
|
|
45
|
+
* reintroduce the identity churn this hook exists to avoid. Memoizing per
|
|
46
|
+
* (wrapper, wrapped component) pair keeps the composed component referentially
|
|
47
|
+
* stable for as long as both inputs are.
|
|
48
|
+
*/
|
|
49
|
+
const composedWrappers = new WeakMap<object, WeakMap<object, unknown>>()
|
|
50
|
+
|
|
51
|
+
function applyWrapper<TProps>(
|
|
52
|
+
wrapper: (Original: ComponentType<TProps>) => ComponentType<TProps>,
|
|
53
|
+
Base: ComponentType<TProps>,
|
|
54
|
+
): ComponentType<TProps> {
|
|
55
|
+
let byBase = composedWrappers.get(wrapper)
|
|
56
|
+
if (!byBase) {
|
|
57
|
+
byBase = new WeakMap<object, unknown>()
|
|
58
|
+
composedWrappers.set(wrapper, byBase)
|
|
59
|
+
}
|
|
60
|
+
const cached = byBase.get(Base)
|
|
61
|
+
if (cached) return cached as ComponentType<TProps>
|
|
62
|
+
const composed = wrapper(Base)
|
|
63
|
+
byBase.set(Base, composed)
|
|
64
|
+
return composed
|
|
65
|
+
}
|
|
66
|
+
|
|
43
67
|
function resolveComponent<TProps>(
|
|
44
68
|
componentId: string,
|
|
45
69
|
fallback: ComponentType<TProps> | undefined,
|
|
@@ -75,7 +99,7 @@ function resolveComponent<TProps>(
|
|
|
75
99
|
}
|
|
76
100
|
|
|
77
101
|
const base = replacement ?? original
|
|
78
|
-
const wrapped = wrappers.reduce<ComponentType<TProps>>((acc, wrapper) => wrapper
|
|
102
|
+
const wrapped = wrappers.reduce<ComponentType<TProps>>((acc, wrapper) => applyWrapper(wrapper, acc), base)
|
|
79
103
|
|
|
80
104
|
return {
|
|
81
105
|
original,
|
|
@@ -87,8 +111,9 @@ function resolveComponent<TProps>(
|
|
|
87
111
|
}
|
|
88
112
|
|
|
89
113
|
/**
|
|
90
|
-
* The returned component's identity must depend only on `componentId`
|
|
91
|
-
*
|
|
114
|
+
* The returned component's identity must depend only on `componentId` — never
|
|
115
|
+
* on the override registry, and never on the `fallback` a caller happens to
|
|
116
|
+
* pass on this render.
|
|
92
117
|
*
|
|
93
118
|
* Overrides arrive asynchronously: `ComponentOverridesBootstrap` dynamically
|
|
94
119
|
* imports the generated override module and hands the provider a fresh array,
|
|
@@ -103,18 +128,32 @@ function resolveComponent<TProps>(
|
|
|
103
128
|
* carries no override for this id, `wrapped` keeps its previous identity and
|
|
104
129
|
* React reconciles in place; only a genuine replacement or wrapper swaps the
|
|
105
130
|
* rendered type, where a remount is the correct behaviour.
|
|
131
|
+
*
|
|
132
|
+
* The identity is held in a ref rather than a `useMemo`, because `useMemo` is a
|
|
133
|
+
* performance hint React is free to discard, and identity here is a correctness
|
|
134
|
+
* requirement rather than an optimisation. `fallback` is read through a ref for
|
|
135
|
+
* the same reason: a host that builds its fallback inline would otherwise swap
|
|
136
|
+
* the component this hook hands back on every render. That still cannot save a
|
|
137
|
+
* subtree rendered *through* such a fallback — the fallback itself is then the
|
|
138
|
+
* element type, and only the host can stabilise it — but it keeps the churn
|
|
139
|
+
* from spreading to hosts whose id does resolve to a registered component.
|
|
106
140
|
*/
|
|
107
141
|
export function useRegisteredComponent<TProps>(
|
|
108
142
|
componentId: string,
|
|
109
143
|
fallback?: ComponentType<TProps>,
|
|
110
144
|
): ComponentType<TProps> {
|
|
111
|
-
|
|
145
|
+
const fallbackRef = React.useRef<ComponentType<TProps> | undefined>(fallback)
|
|
146
|
+
fallbackRef.current = fallback
|
|
147
|
+
|
|
148
|
+
const registered = React.useRef<{ componentId: string; Component: ComponentType<TProps> } | null>(null)
|
|
149
|
+
if (!registered.current || registered.current.componentId !== componentId) {
|
|
112
150
|
const Registered = (props: TProps) => {
|
|
113
151
|
const userFeatures = useOverrideUserFeatures()
|
|
114
152
|
const overrideRevision = useOverrideRegistryRevision()
|
|
153
|
+
const currentFallback = fallbackRef.current
|
|
115
154
|
const { original, wrapped, transforms, replacementOverride, replacementModule } = React.useMemo(
|
|
116
|
-
() => resolveComponent<TProps>(componentId,
|
|
117
|
-
[overrideRevision, userFeatures],
|
|
155
|
+
() => resolveComponent<TProps>(componentId, currentFallback, userFeatures),
|
|
156
|
+
[currentFallback, overrideRevision, userFeatures],
|
|
118
157
|
)
|
|
119
158
|
|
|
120
159
|
if (!original || !wrapped) return null
|
|
@@ -145,8 +184,10 @@ export function useRegisteredComponent<TProps>(
|
|
|
145
184
|
}
|
|
146
185
|
|
|
147
186
|
Registered.displayName = `RegisteredComponent(${componentId})`
|
|
148
|
-
|
|
149
|
-
}
|
|
187
|
+
registered.current = { componentId, Component: Registered }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return registered.current.Component
|
|
150
191
|
}
|
|
151
192
|
|
|
152
193
|
export default useRegisteredComponent
|