react-magic-portal 1.1.3 → 1.1.4
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/CHANGELOG.md +7 -0
- package/README.md +82 -0
- package/__tests__/eslint.config.ts +1 -1
- package/package.json +1 -1
- package/packages/component/dist/index.js.map +1 -1
- package/packages/component/eslint.config.ts +1 -1
- package/packages/component/src/index.ts +1 -1
- package/packages/example/eslint.config.ts +1 -1
- package/packages/example/src/components/portal-content.tsx +7 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
## [1.1.4](https://github.com/molvqingtai/react-magic-portal/compare/v1.1.3...v1.1.4) (2025-09-26)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Performance Improvements
|
|
5
|
+
|
|
6
|
+
* add ref forward notes ([4e36266](https://github.com/molvqingtai/react-magic-portal/commit/4e36266df2724c2c463e8877fd9d424c796aa6da))
|
|
7
|
+
|
|
1
8
|
## [1.1.3](https://github.com/molvqingtai/react-magic-portal/compare/v1.1.2...v1.1.3) (2025-09-25)
|
|
2
9
|
|
|
3
10
|
|
package/README.md
CHANGED
|
@@ -190,6 +190,88 @@ Adds content as a sibling after the anchor element:
|
|
|
190
190
|
<!-- Portal content appears here -->
|
|
191
191
|
```
|
|
192
192
|
|
|
193
|
+
## Important Notes
|
|
194
|
+
|
|
195
|
+
### React Component Ref Requirements
|
|
196
|
+
|
|
197
|
+
When using React components as children, they **must** support ref forwarding to work correctly with MagicPortal. This is because MagicPortal needs to access the underlying DOM element to position it correctly.
|
|
198
|
+
|
|
199
|
+
#### ✅ Works - Components with ref props (React 19+)
|
|
200
|
+
|
|
201
|
+
```jsx
|
|
202
|
+
interface MyComponentProps {
|
|
203
|
+
ref?: React.Ref<HTMLDivElement>
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const MyComponent = ({ ref, ...props }: MyComponentProps) => {
|
|
207
|
+
return <div ref={ref}>My Component Content</div>
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// This will work correctly
|
|
211
|
+
<MagicPortal anchor="#target">
|
|
212
|
+
<MyComponent />
|
|
213
|
+
</MagicPortal>
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
#### ✅ Works - Components with forwardRef (React 18 and earlier)
|
|
217
|
+
|
|
218
|
+
```jsx
|
|
219
|
+
import { forwardRef } from 'react'
|
|
220
|
+
|
|
221
|
+
const MyComponent = forwardRef<HTMLDivElement>((props, ref) => {
|
|
222
|
+
return <div ref={ref}>My Component Content</div>
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
// This will work correctly
|
|
226
|
+
<MagicPortal anchor="#target">
|
|
227
|
+
<MyComponent />
|
|
228
|
+
</MagicPortal>
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
#### ✅ Works - Direct DOM elements
|
|
232
|
+
|
|
233
|
+
```jsx
|
|
234
|
+
// Direct DOM elements always work
|
|
235
|
+
<MagicPortal anchor="#target">
|
|
236
|
+
<div>Direct DOM element</div>
|
|
237
|
+
</MagicPortal>
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
#### ❌ Won't work - Components without ref support
|
|
241
|
+
|
|
242
|
+
```jsx
|
|
243
|
+
const MyComponent = () => {
|
|
244
|
+
return <div>My Component Content</div>
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// This won't position correctly because ref cannot be passed to the component
|
|
248
|
+
<MagicPortal anchor="#target">
|
|
249
|
+
<MyComponent />
|
|
250
|
+
</MagicPortal>
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
#### ✅ Solution - Wrap with DOM element
|
|
254
|
+
|
|
255
|
+
```jsx
|
|
256
|
+
const MyComponent = () => {
|
|
257
|
+
return <div>My Component Content</div>
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Wrap the component in a DOM element
|
|
261
|
+
<MagicPortal anchor="#target">
|
|
262
|
+
<div>
|
|
263
|
+
<MyComponent />
|
|
264
|
+
</div>
|
|
265
|
+
</MagicPortal>
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
### Browser Extension Development Tips
|
|
269
|
+
|
|
270
|
+
- **Test dynamic content scenarios** - Many web pages load content asynchronously
|
|
271
|
+
- **Handle multiple SPA navigation** - Single Page Applications may recreate elements frequently
|
|
272
|
+
- **Monitor for anchor disappearance** - Use `onUnmount` callback to handle cleanup
|
|
273
|
+
- **Use specific selectors** - Avoid overly generic CSS selectors that might match unintended elements
|
|
274
|
+
|
|
193
275
|
## License
|
|
194
276
|
|
|
195
277
|
MIT © [molvqingtai](https://github.com/molvqingtai)
|
|
@@ -11,7 +11,7 @@ export default defineConfig([
|
|
|
11
11
|
ignores: ['**/dist/*']
|
|
12
12
|
},
|
|
13
13
|
{
|
|
14
|
-
files: ['**/*.{js,mjs,cjs,ts}'],
|
|
14
|
+
files: ['**/*.{js,jsx,mjs,cjs,ts,tsx}'],
|
|
15
15
|
languageOptions: {
|
|
16
16
|
globals: { ...globals.browser, ...globals.node },
|
|
17
17
|
parserOptions: { project: './tsconfig.json', tsconfigRootDir: import.meta.dirname }
|
package/package.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":["container"],"sources":["../src/index.ts"],"sourcesContent":["import React, { useEffect, useState, useRef, useCallback, useLayoutEffect } from 'react'\nimport ReactDOM from 'react-dom'\n\nexport interface MagicPortalProps {\n anchor: string | (() => Element | null) | Element | React.RefObject<Element | null> | null\n position?: 'append' | 'prepend' | 'before' | 'after'\n children?: React.ReactElement | React.ReactElement[]\n onMount?: (anchor: Element, container: Element) => void\n onUnmount?: (anchor: Element, container: Element) => void\n key?: React.Key\n}\n\n/**\n * https://github.com/radix-ui/primitives/blob/36d954d3c1b41c96b1d2e875b93fc9362c8c09e6/packages/react/slot/src/slot.tsx#L166\n */\nconst getElementRef = (element: React.ReactElement) => {\n // React <=18 in DEV\n let getter = Object.getOwnPropertyDescriptor(element.props, 'ref')?.get\n let mayWarn = getter && 'isReactWarning' in getter && getter.isReactWarning\n if (mayWarn) {\n return (element as any).ref as React.Ref<Element>\n }\n // React 19 in DEV\n getter = Object.getOwnPropertyDescriptor(element, 'ref')?.get\n mayWarn = getter && 'isReactWarning' in getter && getter.isReactWarning\n if (mayWarn) {\n return (element.props as { ref?: React.Ref<Element> }).ref\n }\n\n // Not DEV\n return (element.props as { ref?: React.Ref<Element> }).ref || ((element as any).ref as React.Ref<Element>)\n}\n\nconst resolveAnchor = (anchor: MagicPortalProps['anchor']) => {\n if (typeof anchor === 'string') {\n return document.querySelector(anchor)\n } else if (typeof anchor === 'function') {\n return anchor()\n } else if (anchor && 'current' in anchor) {\n return anchor.current\n } else {\n return anchor\n }\n}\n\nconst mergeRef = <T extends Element | null>(...refs: (React.Ref<T> | undefined)[]) => {\n return (node: T) =>\n refs.forEach((ref) => {\n if (typeof ref === 'function') {\n ref(node)\n } else if (ref) {\n ref.current = node\n }\n })\n}\n\nconst MagicPortal = ({ anchor, position = 'append', children, onMount, onUnmount, key }: MagicPortalProps) => {\n const anchorRef = useRef<Element | null>(null)\n const [container, setContainer] = useState<Element | null>(null)\n\n const nodes = React.Children.map(children, (item) => {\n if (!React.isValidElement(item)) {\n return null\n }\n const originalRef = getElementRef(item)\n return React.cloneElement(item as React.ReactElement<any>, {\n ref: mergeRef(originalRef, (node
|
|
1
|
+
{"version":3,"file":"index.js","names":["container"],"sources":["../src/index.ts"],"sourcesContent":["import React, { useEffect, useState, useRef, useCallback, useLayoutEffect } from 'react'\nimport ReactDOM from 'react-dom'\n\nexport interface MagicPortalProps {\n anchor: string | (() => Element | null) | Element | React.RefObject<Element | null> | null\n position?: 'append' | 'prepend' | 'before' | 'after'\n children?: React.ReactElement | React.ReactElement[]\n onMount?: (anchor: Element, container: Element) => void\n onUnmount?: (anchor: Element, container: Element) => void\n key?: React.Key\n}\n\n/**\n * https://github.com/radix-ui/primitives/blob/36d954d3c1b41c96b1d2e875b93fc9362c8c09e6/packages/react/slot/src/slot.tsx#L166\n */\nconst getElementRef = (element: React.ReactElement) => {\n // React <=18 in DEV\n let getter = Object.getOwnPropertyDescriptor(element.props, 'ref')?.get\n let mayWarn = getter && 'isReactWarning' in getter && getter.isReactWarning\n if (mayWarn) {\n return (element as any).ref as React.Ref<Element>\n }\n // React 19 in DEV\n getter = Object.getOwnPropertyDescriptor(element, 'ref')?.get\n mayWarn = getter && 'isReactWarning' in getter && getter.isReactWarning\n if (mayWarn) {\n return (element.props as { ref?: React.Ref<Element> }).ref\n }\n\n // Not DEV\n return (element.props as { ref?: React.Ref<Element> }).ref || ((element as any).ref as React.Ref<Element>)\n}\n\nconst resolveAnchor = (anchor: MagicPortalProps['anchor']) => {\n if (typeof anchor === 'string') {\n return document.querySelector(anchor)\n } else if (typeof anchor === 'function') {\n return anchor()\n } else if (anchor && 'current' in anchor) {\n return anchor.current\n } else {\n return anchor\n }\n}\n\nconst mergeRef = <T extends Element | null>(...refs: (React.Ref<T> | undefined)[]) => {\n return (node: T) =>\n refs.forEach((ref) => {\n if (typeof ref === 'function') {\n ref(node)\n } else if (ref) {\n ref.current = node\n }\n })\n}\n\nconst MagicPortal = ({ anchor, position = 'append', children, onMount, onUnmount, key }: MagicPortalProps) => {\n const anchorRef = useRef<Element | null>(null)\n const [container, setContainer] = useState<Element | null>(null)\n\n const nodes = React.Children.map(children, (item) => {\n if (!React.isValidElement(item)) {\n return null\n }\n const originalRef = getElementRef(item)\n return React.cloneElement(item as React.ReactElement<any>, {\n ref: mergeRef(originalRef, (node) => {\n const positionMap = {\n before: 'beforebegin',\n prepend: 'afterbegin',\n append: 'beforeend',\n after: 'afterend'\n } as const\n node && anchorRef.current?.insertAdjacentElement(positionMap[position], node)\n })\n })\n })\n\n const update = useCallback(() => {\n anchorRef.current = resolveAnchor(anchor)\n const container =\n position === 'prepend' || position === 'append' ? anchorRef.current : (anchorRef.current?.parentElement ?? null)\n setContainer(container)\n }, [anchor, position])\n\n useLayoutEffect(() => {\n update()\n\n const observer = new MutationObserver((mutations) => {\n const shouldUpdate = mutations.some((mutation) => {\n const { addedNodes, removedNodes } = mutation\n // Check if current anchor is removed\n if (anchorRef.current && Array.from(removedNodes).includes(anchorRef.current)) {\n return true\n }\n // Only check added nodes when anchor is a string selector\n if (\n typeof anchor === 'string' &&\n [...addedNodes].some((node) => node instanceof Element && node.matches?.(anchor))\n ) {\n return true\n }\n return false\n })\n shouldUpdate && update()\n })\n\n observer.observe(document.body, {\n childList: true,\n subtree: true\n })\n\n return () => observer.disconnect()\n }, [update, anchor])\n\n useEffect(() => {\n if (container && anchorRef.current) {\n onMount?.(anchorRef.current, container)\n return () => {\n onUnmount?.(anchorRef.current!, container)\n }\n }\n }, [onMount, onUnmount, container])\n\n return container && ReactDOM.createPortal(nodes, container, key)\n}\n\nMagicPortal.displayName = 'MagicPortal'\n\nexport default MagicPortal\n"],"mappings":"8HAeA,MAAM,EAAiB,GAAgC,CAErD,IAAI,EAAS,OAAO,yBAAyB,EAAQ,MAAO,MAAM,EAAE,IAChE,EAAU,GAAU,mBAAoB,GAAU,EAAO,eAY7D,OAXI,EACM,EAAgB,KAG1B,EAAS,OAAO,yBAAyB,EAAS,MAAM,EAAE,IAC1D,EAAU,GAAU,mBAAoB,GAAU,EAAO,eACrD,EACM,EAAQ,MAAuC,IAIjD,EAAQ,MAAuC,KAAS,EAAgB,MAG5E,EAAiB,GACjB,OAAO,GAAW,SACb,SAAS,cAAc,EAAO,CAC5B,OAAO,GAAW,WACpB,GAAQ,CACN,GAAU,YAAa,EACzB,EAAO,QAEP,EAIL,GAAsC,GAAG,IACrC,GACN,EAAK,QAAS,GAAQ,CAChB,OAAO,GAAQ,WACjB,EAAI,EAAK,CACA,IACT,EAAI,QAAU,IAEhB,CAGA,GAAe,CAAE,SAAQ,WAAW,SAAU,WAAU,UAAS,YAAW,SAA4B,CAC5G,IAAM,EAAY,EAAuB,KAAK,CACxC,CAAC,EAAW,GAAgB,EAAyB,KAAK,CAE1D,EAAQ,EAAM,SAAS,IAAI,EAAW,GAAS,CACnD,GAAI,CAAC,EAAM,eAAe,EAAK,CAC7B,OAAO,KAET,IAAM,EAAc,EAAc,EAAK,CACvC,OAAO,EAAM,aAAa,EAAiC,CACzD,IAAK,EAAS,EAAc,GAAS,CAOnC,GAAQ,EAAU,SAAS,sBANP,CAClB,OAAQ,cACR,QAAS,aACT,OAAQ,YACR,MAAO,WACR,CAC4D,GAAW,EAAK,EAC7E,CACH,CAAC,EACF,CAEI,EAAS,MAAkB,CAC/B,EAAU,QAAU,EAAc,EAAO,CACzC,IAAMA,EACJ,IAAa,WAAa,IAAa,SAAW,EAAU,QAAW,EAAU,SAAS,eAAiB,KAC7G,EAAaA,EAAU,EACtB,CAAC,EAAQ,EAAS,CAAC,CAyCtB,OAvCA,MAAsB,CACpB,GAAQ,CAER,IAAM,EAAW,IAAI,iBAAkB,GAAc,CAC9B,EAAU,KAAM,GAAa,CAChD,GAAM,CAAE,aAAY,gBAAiB,EAYrC,MANA,GAJI,EAAU,SAAW,MAAM,KAAK,EAAa,CAAC,SAAS,EAAU,QAAQ,EAK3E,OAAO,GAAW,UAClB,CAAC,GAAG,EAAW,CAAC,KAAM,GAAS,aAAgB,SAAW,EAAK,UAAU,EAAO,CAAC,GAKnF,EACc,GAAQ,EACxB,CAOF,OALA,EAAS,QAAQ,SAAS,KAAM,CAC9B,UAAW,GACX,QAAS,GACV,CAAC,KAEW,EAAS,YAAY,EACjC,CAAC,EAAQ,EAAO,CAAC,CAEpB,MAAgB,CACd,GAAI,GAAa,EAAU,QAEzB,OADA,IAAU,EAAU,QAAS,EAAU,KAC1B,CACX,IAAY,EAAU,QAAU,EAAU,GAG7C,CAAC,EAAS,EAAW,EAAU,CAAC,CAE5B,GAAa,EAAS,aAAa,EAAO,EAAW,EAAI,EAGlE,EAAY,YAAc,cAE1B,IAAA,EAAe"}
|
|
@@ -11,7 +11,7 @@ export default defineConfig([
|
|
|
11
11
|
ignores: ['**/dist/*']
|
|
12
12
|
},
|
|
13
13
|
{
|
|
14
|
-
files: ['**/*.{js,mjs,cjs,ts}'],
|
|
14
|
+
files: ['**/*.{js,jsx,mjs,cjs,ts,tsx}'],
|
|
15
15
|
languageOptions: {
|
|
16
16
|
globals: { ...globals.browser, ...globals.node },
|
|
17
17
|
parserOptions: { project: './tsconfig.json', tsconfigRootDir: import.meta.dirname }
|
|
@@ -64,7 +64,7 @@ const MagicPortal = ({ anchor, position = 'append', children, onMount, onUnmount
|
|
|
64
64
|
}
|
|
65
65
|
const originalRef = getElementRef(item)
|
|
66
66
|
return React.cloneElement(item as React.ReactElement<any>, {
|
|
67
|
-
ref: mergeRef(originalRef, (node
|
|
67
|
+
ref: mergeRef(originalRef, (node) => {
|
|
68
68
|
const positionMap = {
|
|
69
69
|
before: 'beforebegin',
|
|
70
70
|
prepend: 'afterbegin',
|
|
@@ -11,7 +11,7 @@ export default defineConfig([
|
|
|
11
11
|
ignores: ['**/dist/*']
|
|
12
12
|
},
|
|
13
13
|
{
|
|
14
|
-
files: ['**/*.{js,mjs,cjs,ts}'],
|
|
14
|
+
files: ['**/*.{js,jsx,mjs,cjs,ts,tsx}'],
|
|
15
15
|
languageOptions: {
|
|
16
16
|
globals: { ...globals.browser, ...globals.node },
|
|
17
17
|
parserOptions: { project: './tsconfig.json', tsconfigRootDir: import.meta.dirname }
|
|
@@ -2,9 +2,10 @@ import { useEffect } from 'react'
|
|
|
2
2
|
|
|
3
3
|
interface PortalContentProps {
|
|
4
4
|
position: string
|
|
5
|
+
ref?: React.Ref<HTMLDivElement>
|
|
5
6
|
}
|
|
6
7
|
|
|
7
|
-
function PortalContent({ position }: PortalContentProps) {
|
|
8
|
+
function PortalContent({ position, ref }: PortalContentProps) {
|
|
8
9
|
useEffect(() => {
|
|
9
10
|
console.log(`✅ ${position} Portal useEffect mounted`)
|
|
10
11
|
return () => {
|
|
@@ -27,7 +28,11 @@ function PortalContent({ position }: PortalContentProps) {
|
|
|
27
28
|
}
|
|
28
29
|
}
|
|
29
30
|
|
|
30
|
-
return
|
|
31
|
+
return (
|
|
32
|
+
<div ref={ref} className={`portal-content ${position}`}>
|
|
33
|
+
{getContentText()}
|
|
34
|
+
</div>
|
|
35
|
+
)
|
|
31
36
|
}
|
|
32
37
|
|
|
33
38
|
export default PortalContent
|