@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.
@@ -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(acc), base);
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
- return React.useMemo(() => {
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, fallback, userFeatures),
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
- return Registered;
92
- }, [componentId, fallback]);
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(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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",
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": "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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.7059.1.f45d1ae6a1",
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.7059.1.f45d1ae6a1",
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.7059.1.f45d1ae6a1",
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(acc), base)
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` and
91
- * `fallback` never on the override registry.
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
- return React.useMemo(() => {
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, fallback, userFeatures),
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
- return Registered
149
- }, [componentId, fallback])
187
+ registered.current = { componentId, Component: Registered }
188
+ }
189
+
190
+ return registered.current.Component
150
191
  }
151
192
 
152
193
  export default useRegisteredComponent