react-magic-portal 1.1.8 → 1.2.0

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 CHANGED
@@ -1,3 +1,22 @@
1
+ # [1.2.0](https://github.com/molvqingtai/react-magic-portal/compare/v1.1.9...v1.2.0) (2025-10-10)
2
+
3
+
4
+ ### Features
5
+
6
+ * add root prop to customize MutationObserver scope ([e27aeeb](https://github.com/molvqingtai/react-magic-portal/commit/e27aeeb70ae2b93f9f6ac80d25deb436ca125065))
7
+
8
+
9
+ ### Performance Improvements
10
+
11
+ * optimize node insertion with position check ([866947b](https://github.com/molvqingtai/react-magic-portal/commit/866947bba294d2887c30a9ce4670ecf6b5429b05))
12
+
13
+ ## [1.1.9](https://github.com/molvqingtai/react-magic-portal/compare/v1.1.8...v1.1.9) (2025-10-09)
14
+
15
+
16
+ ### Bug Fixes
17
+
18
+ * remove key prop from MagicPortal to prevent React warning ([2ac6cc1](https://github.com/molvqingtai/react-magic-portal/commit/2ac6cc1d91e33b3bb5e257ed0d36d5c546b2ccd9))
19
+
1
20
  ## [1.1.8](https://github.com/molvqingtai/react-magic-portal/compare/v1.1.7...v1.1.8) (2025-09-30)
2
21
 
3
22
 
package/README.md CHANGED
@@ -97,14 +97,14 @@ function App() {
97
97
 
98
98
  ### Props
99
99
 
100
- | Prop | Type | Default | Description |
101
- | ----------- | ------------------------------------------------------------------------------------------ | ------------ | ------------------------------------------------------------ |
102
- | `anchor` | `string \| (() => Element \| null) \| Element \| React.RefObject<Element \| null> \| null` | **Required** | The target element where the portal content will be rendered |
103
- | `position` | `'append' \| 'prepend' \| 'before' \| 'after'` | `'append'` | Position relative to the anchor element |
104
- | `children` | `React.ReactElement \| React.ReactElement[]` | `undefined` | The content to render in the portal |
105
- | `onMount` | `(anchor: Element, container: Element) => void` | `undefined` | Callback fired when the portal is mounted |
106
- | `onUnmount` | `(anchor: Element, container: Element) => void` | `undefined` | Callback fired when the portal is unmounted |
107
- | `key` | `React.Key` | `undefined` | Key for the ReactDOM.createPortal |
100
+ | Prop | Type | Default | Description |
101
+ | ----------- | ------------------------------------------------------------------------------------------ | --------------- | ------------------------------------------------------------ |
102
+ | `anchor` | `string \| (() => Element \| null) \| Element \| React.RefObject<Element \| null> \| null` | **Required** | The target element where the portal content will be rendered |
103
+ | `position` | `'append' \| 'prepend' \| 'before' \| 'after'` | `'append'` | Position relative to the anchor element |
104
+ | `root` | `Element` | `document.body` | The root element to observe for DOM mutations |
105
+ | `children` | `React.ReactElement \| React.ReactElement[]` | `undefined` | The content to render in the portal |
106
+ | `onMount` | `(anchor: Element, container: Element) => void` | `undefined` | Callback fired when the portal is mounted |
107
+ | `onUnmount` | `(anchor: Element, container: Element) => void` | `undefined` | Callback fired when the portal is unmounted |
108
108
 
109
109
  ### Anchor Types
110
110
 
@@ -3,8 +3,7 @@ import pluginJs from '@eslint/js'
3
3
  import { defineConfig } from 'eslint/config'
4
4
  import tseslint from 'typescript-eslint'
5
5
  import prettierPlugin from 'eslint-plugin-prettier/recommended'
6
- import reactHooks from 'eslint-plugin-react-hooks'
7
- import reactRefresh from 'eslint-plugin-react-refresh'
6
+ import eslintReact from '@eslint-react/eslint-plugin'
8
7
 
9
8
  export default defineConfig([
10
9
  {
@@ -18,13 +17,13 @@ export default defineConfig([
18
17
  }
19
18
  },
20
19
  pluginJs.configs.recommended,
21
- ...tseslint.configs.recommended,
20
+ tseslint.configs.recommended,
21
+ eslintReact.configs['recommended-typescript'],
22
22
  prettierPlugin,
23
- reactHooks.configs['recommended-latest'],
24
- reactRefresh.configs.vite,
25
23
  {
26
24
  rules: {
27
- '@typescript-eslint/no-explicit-any': 'off'
25
+ '@typescript-eslint/no-explicit-any': 'off',
26
+ '@eslint-react/no-forward-ref': 'off'
28
27
  }
29
28
  }
30
29
  ])
@@ -18,25 +18,24 @@
18
18
  "react-magic-portal": "workspace:*"
19
19
  },
20
20
  "devDependencies": {
21
- "@eslint/js": "^9.36.0",
21
+ "@eslint-react/eslint-plugin": "^2.0.6",
22
+ "@eslint/js": "^9.37.0",
22
23
  "@testing-library/react": "^16.3.0",
23
24
  "@testing-library/user-event": "^14.6.1",
24
- "@types/react": "^19.1.15",
25
- "@types/react-dom": "^19.1.9",
25
+ "@types/react": "^19.2.2",
26
+ "@types/react-dom": "^19.2.1",
26
27
  "@vitejs/plugin-react": "^5.0.4",
27
28
  "@vitest/coverage-v8": "^3.2.4",
28
29
  "@vitest/ui": "^3.2.4",
29
- "eslint": "^9.36.0",
30
+ "eslint": "^9.37.0",
30
31
  "eslint-plugin-prettier": "^5.5.4",
31
- "eslint-plugin-react-hooks": "^5.2.0",
32
- "eslint-plugin-react-refresh": "^0.4.22",
33
32
  "globals": "^16.4.0",
34
- "happy-dom": "^19.0.2",
35
- "react": "^19.1.1",
36
- "react-dom": "^19.1.1",
37
- "typescript": "^5.9.2",
38
- "typescript-eslint": "^8.45.0",
39
- "vite": "^7.1.7",
33
+ "happy-dom": "^20.0.0",
34
+ "react": "^19.2.0",
35
+ "react-dom": "^19.2.0",
36
+ "typescript": "^5.9.3",
37
+ "typescript-eslint": "^8.46.0",
38
+ "vite": "^7.1.9",
40
39
  "vitest": "^3.2.4"
41
40
  }
42
41
  }
@@ -47,7 +47,9 @@ describe('MagicPortal', () => {
47
47
 
48
48
  return (
49
49
  <div>
50
- <button onClick={() => setShowAnchor(true)}>Show Anchor</button>
50
+ <button type="button" onClick={() => setShowAnchor(true)}>
51
+ Show Anchor
52
+ </button>
51
53
  {showAnchor && <div id="dynamic-anchor">Anchor</div>}
52
54
  <MagicPortal anchor="#dynamic-anchor">
53
55
  <div data-testid="portal-content">Portal Content</div>
@@ -74,7 +76,9 @@ describe('MagicPortal', () => {
74
76
 
75
77
  return (
76
78
  <div>
77
- <button onClick={() => setShowAnchor(false)}>Hide Anchor</button>
79
+ <button type="button" onClick={() => setShowAnchor(false)}>
80
+ Hide Anchor
81
+ </button>
78
82
  {showAnchor && <div id="dynamic-anchor">Anchor</div>}
79
83
  <MagicPortal anchor="#dynamic-anchor">
80
84
  <div data-testid="portal-content">Portal Content</div>
@@ -435,22 +439,31 @@ describe('MagicPortal', () => {
435
439
 
436
440
  it('should work with forwardRef components as portal content', () => {
437
441
  const ForwardedComponent = React.forwardRef<HTMLButtonElement, { children: React.ReactNode }>(
438
- ({ children }, ref) => <button ref={ref}>{children}</button>
442
+ ({ children }, forwardedRef) => (
443
+ <button type="button" ref={forwardedRef}>
444
+ {children}
445
+ </button>
446
+ )
439
447
  )
440
448
 
441
- const buttonRef = vi.fn()
449
+ ForwardedComponent.displayName = 'ForwardedComponent'
450
+
451
+ const refCalls: Array<HTMLButtonElement | null> = []
452
+ const handleButtonRef: React.RefCallback<HTMLButtonElement> = (element) => {
453
+ refCalls.push(element)
454
+ }
442
455
  const anchor = document.createElement('div')
443
456
  anchor.id = 'forward-ref-anchor'
444
457
  document.body.appendChild(anchor)
445
458
 
446
459
  render(
447
460
  <MagicPortal anchor="#forward-ref-anchor">
448
- <ForwardedComponent ref={buttonRef}>Click me</ForwardedComponent>
461
+ <ForwardedComponent ref={handleButtonRef}>Click me</ForwardedComponent>
449
462
  </MagicPortal>
450
463
  )
451
464
 
452
- expect(buttonRef).toHaveBeenCalledWith(expect.any(HTMLButtonElement))
453
- expect(buttonRef.mock.calls[0][0]?.textContent).toBe('Click me')
465
+ expect(refCalls[0]).toBeInstanceOf(HTMLButtonElement)
466
+ expect(refCalls[0]?.textContent).toBe('Click me')
454
467
  })
455
468
 
456
469
  it('should handle single element content', () => {
@@ -527,7 +540,9 @@ describe('MagicPortal', () => {
527
540
 
528
541
  return (
529
542
  <div>
530
- <button onClick={() => setShowFirst(!showFirst)}>Toggle Content</button>
543
+ <button type="button" onClick={() => setShowFirst(!showFirst)}>
544
+ Toggle Content
545
+ </button>
531
546
  <MagicPortal anchor="#dynamic-content-anchor">
532
547
  {showFirst ? (
533
548
  <div ref={ref1} data-testid="first-content">
@@ -601,7 +616,9 @@ describe('MagicPortal', () => {
601
616
 
602
617
  return (
603
618
  <div>
604
- <button onClick={addElement}>Add Target Element</button>
619
+ <button type="button" onClick={addElement}>
620
+ Add Target Element
621
+ </button>
605
622
  <MagicPortal anchor=".dynamic-target">
606
623
  <div data-testid="portal-content">Portal Content</div>
607
624
  </MagicPortal>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-magic-portal",
3
- "version": "1.1.8",
3
+ "version": "1.2.0",
4
4
  "description": "React Portal with dynamic mounting support",
5
5
  "main": "packages/component/dist/index.js",
6
6
  "type": "module",
@@ -40,7 +40,7 @@
40
40
  },
41
41
  "homepage": "https://github.com/molvqingtai/react-magic-portal#readme",
42
42
  "dependencies": {
43
- "@commitlint/cli": "^20.0.0",
43
+ "@commitlint/cli": "^20.1.0",
44
44
  "@commitlint/config-conventional": "^20.0.0",
45
45
  "@semantic-release/changelog": "^6.0.3",
46
46
  "@semantic-release/git": "^10.0.1",
@@ -4,19 +4,19 @@ import React from "react";
4
4
  interface MagicPortalProps {
5
5
  anchor: string | (() => Element | null) | Element | React.RefObject<Element | null> | null;
6
6
  position?: 'append' | 'prepend' | 'before' | 'after';
7
+ root?: Element;
7
8
  children?: React.ReactElement | React.ReactElement[];
8
9
  onMount?: (anchor: Element, container: Element) => void;
9
10
  onUnmount?: (anchor: Element, container: Element) => void;
10
- key?: React.Key;
11
11
  }
12
12
  declare const MagicPortal: {
13
13
  ({
14
14
  anchor,
15
15
  position,
16
+ root,
16
17
  children,
17
18
  onMount,
18
- onUnmount,
19
- key
19
+ onUnmount
20
20
  }: MagicPortalProps): React.ReactPortal | null;
21
21
  displayName: string;
22
22
  };
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","names":[],"sources":["../src/index.ts"],"sourcesContent":[],"mappings":";;;UAGiB,gBAAA;0BACS,kBAAkB,UAAU,KAAA,CAAM,UAAU;EADrD,QAAA,CAAA,EAAA,QAAgB,GAAA,SAAA,GAAA,QAAA,GAAA,OAAA;EAAA,QAAA,CAAA,EAGpB,KAAA,CAAM,YAHc,GAGC,KAAA,CAAM,YAHP,EAAA;SACP,CAAA,EAAA,CAAA,MAAA,EAGL,OAHK,EAAA,SAAA,EAGe,OAHf,EAAA,GAAA,IAAA;WAAkB,CAAA,EAAA,CAAA,MAAA,EAIrB,OAJqB,EAAA,SAAA,EAID,OAJC,EAAA,GAAA,IAAA;KAA0B,CAAA,EAK9D,KAAA,CAAM,GALwD;;cA4DhE,WA1Da,EAAA;;IAAe,MAAM;IAAA,QAAA;IAAA,QAAA;IAAA,OAAA;IAAA,SAAA;IAAA;EAAA,CAAA,EA0DiD,gBA1DjD,CAAA,EA0DiE,KAAA,CAAA,WA1DjE,GAAA,IAAA;aACnB,EAAA,MAAA"}
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../src/index.ts"],"sourcesContent":[],"mappings":";;;UAGiB,gBAAA;0BACS,kBAAkB,UAAU,KAAA,CAAM,UAAU;EADrD,QAAA,CAAA,EAAA,QAAgB,GAAA,SAAA,GAAA,QAAA,GAAA,OAAA;EAAA,IAAA,CAAA,EAGxB,OAHwB;UACP,CAAA,EAGb,KAAA,CAAM,YAHO,GAGQ,KAAA,CAAM,YAHd,EAAA;SAAkB,CAAA,EAAA,CAAA,MAAA,EAIvB,OAJuB,EAAA,SAAA,EAIH,OAJG,EAAA,GAAA,IAAA;WAA0B,CAAA,EAAA,CAAA,MAAA,EAK/C,OAL+C,EAAA,SAAA,EAK3B,OAL2B,EAAA,GAAA,IAAA;;cA4DhE,WA1DG,EAAA;;IACI,MAAM;IAAA,QAAA;IAAA,IAAA;IAAA,QAAA;IAAA,OAAA;IAAA;EAAA,CAAA,EAgEhB,gBAhEgB,CAAA,EAgEA,KAAA,CAAA,WAhEA,GAAA,IAAA;aAAqB,EAAA,MAAA"}
@@ -1,2 +1,2 @@
1
- import e,{useCallback as t,useEffect as n,useLayoutEffect as r,useRef as i,useState as a}from"react";import{createPortal as o}from"react-dom";const s=e=>{let t=Object.getOwnPropertyDescriptor(e.props,`ref`)?.get,n=t&&`isReactWarning`in t&&t.isReactWarning;return n?e.ref:(t=Object.getOwnPropertyDescriptor(e,`ref`)?.get,n=t&&`isReactWarning`in t&&t.isReactWarning,n?e.props.ref:e.props.ref||e.ref)},c=e=>typeof e==`string`?document.querySelector(e):typeof e==`function`?e():e&&`current`in e?e.current:e,l=(e,t)=>{if(typeof e==`function`)return e(t);e!=null&&(e.current=t)},u=(...e)=>t=>{let n=e.map(e=>l(e,t));return()=>n.forEach((t,n)=>typeof t==`function`?t():l(e[n],null))},d=({anchor:l,position:d=`append`,children:f,onMount:p,onUnmount:m,key:h})=>{let g=i(null),[_,v]=a(null),y=e.Children.map(f,t=>{if(!e.isValidElement(t))return null;let n=s(t);return e.cloneElement(t,{ref:u(n,e=>{e&&g.current?.insertAdjacentElement({before:`beforebegin`,prepend:`afterbegin`,append:`beforeend`,after:`afterend`}[d],e)})})}),b=t(()=>{g.current=c(l),v(d===`prepend`||d===`append`?g.current:g.current?.parentElement??null)},[l,d]);return r(()=>{b();let e=new MutationObserver(e=>{!e.flatMap(({addedNodes:e,removedNodes:t})=>[...e,...t]).some(e=>_?.contains(e))&&b()});return e.observe(document.body,{childList:!0,subtree:!0}),()=>e.disconnect()},[b,l,_]),n(()=>{if(_&&g.current)return p?.(g.current,_),()=>{m?.(g.current,_)}},[p,m,_]),_&&o(y,_,h)};d.displayName=`MagicPortal`;var f=d;export{f as default};
1
+ import e,{useCallback as t,useEffect as n,useLayoutEffect as r,useRef as i,useState as a}from"react";import{createPortal as o}from"react-dom";const s=e=>{let t=Object.getOwnPropertyDescriptor(e.props,`ref`)?.get,n=t&&`isReactWarning`in t&&t.isReactWarning;return n?e.ref:(t=Object.getOwnPropertyDescriptor(e,`ref`)?.get,n=t&&`isReactWarning`in t&&t.isReactWarning,n?e.props.ref:e.props.ref||e.ref)},c=e=>typeof e==`string`?document.querySelector(e):typeof e==`function`?e():e&&`current`in e?e.current:e,l=(e,t)=>{if(typeof e==`function`)return e(t);e!=null&&(e.current=t)},u=(...e)=>t=>{let n=e.map(e=>l(e,t));return()=>n.forEach((t,n)=>typeof t==`function`?t():l(e[n],null))},d=({anchor:l,position:d=`append`,root:f=document.body,children:p,onMount:m,onUnmount:h})=>{let g=i(null),[_,v]=a(null),y=t(e=>{if(!e)return;let t=g.current;if(!t)return;let n=d===`prepend`||d===`append`?t:t.parentElement;if(!n)return;let r=e.parentElement===n;if(r)switch(d){case`append`:r=e===n.lastElementChild;break;case`prepend`:r=e===n.firstElementChild;break;case`before`:r=e.nextElementSibling===t;break;case`after`:r=e.previousElementSibling===t;break}r||t.insertAdjacentElement({before:`beforebegin`,prepend:`afterbegin`,append:`beforeend`,after:`afterend`}[d],e)},[d]),b=e.Children.map(p,t=>{if(!e.isValidElement(t))return null;let n=s(t);return e.cloneElement(t,{ref:u(n,y)})}),x=t(()=>{g.current=c(l),v(d===`prepend`||d===`append`?g.current:g.current?.parentElement??null)},[l,d]);return r(()=>{x();let e=new MutationObserver(e=>{!e.flatMap(({addedNodes:e,removedNodes:t})=>[...e,...t]).some(e=>_?.contains(e))&&x()});return e.observe(f,{childList:!0,subtree:!0}),()=>e.disconnect()},[x,l,_,f]),n(()=>{if(_&&g.current)return m?.(g.current,_),()=>{h?.(g.current,_)}},[m,h,_]),_&&o(b,_)};d.displayName=`MagicPortal`;var f=d;export{f as default};
2
2
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../src/index.ts"],"sourcesContent":["import React, { useEffect, useState, useRef, useCallback, useLayoutEffect } from 'react'\nimport { createPortal } 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\n/**\n * https://github.com/facebook/react/blob/d91d28c8ba6fe7c96e651f82fc47c9d5481bf5f9/packages/react-reconciler/src/ReactFiberHooks.js#L2792\n */\nconst setRef = <T>(ref: React.Ref<T> | undefined, value: T) => {\n if (typeof ref === 'function') {\n return ref(value)\n } else if (ref !== null && ref !== undefined) {\n ref.current = value\n }\n}\n\nconst mergeRef = <T extends Element | null>(...refs: (React.Ref<T> | undefined)[]) => {\n return (node: T) => {\n const cleanups = refs.map((ref) => setRef(ref, node))\n return () =>\n cleanups.forEach((cleanup, index) => (typeof cleanup === 'function' ? cleanup() : setRef(refs[index], null)))\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 setContainer(\n position === 'prepend' || position === 'append' ? anchorRef.current : (anchorRef.current?.parentElement ?? null)\n )\n }, [anchor, position])\n\n useLayoutEffect(() => {\n update()\n\n const observer = new MutationObserver((mutations) => {\n const isSelfMutation = mutations\n .flatMap(({ addedNodes, removedNodes }) => [...addedNodes, ...removedNodes])\n .some((node) => container?.contains(node))\n !isSelfMutation && update()\n })\n\n observer.observe(document.body, {\n childList: true,\n subtree: true\n })\n return () => observer.disconnect()\n }, [update, anchor, container])\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 && createPortal(nodes, container, key)\n}\n\nMagicPortal.displayName = 'MagicPortal'\n\nexport default MagicPortal\n"],"mappings":"8IAeA,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,EAOL,GAAa,EAA+B,IAAa,CAC7D,GAAI,OAAO,GAAQ,WACjB,OAAO,EAAI,EAAM,CACR,GAAQ,OACjB,EAAI,QAAU,IAIZ,GAAsC,GAAG,IACrC,GAAY,CAClB,IAAM,EAAW,EAAK,IAAK,GAAQ,EAAO,EAAK,EAAK,CAAC,CACrD,UACE,EAAS,SAAS,EAAS,IAAW,OAAO,GAAY,WAAa,GAAS,CAAG,EAAO,EAAK,GAAQ,KAAK,CAAE,EAI7G,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,EACE,IAAa,WAAa,IAAa,SAAW,EAAU,QAAW,EAAU,SAAS,eAAiB,KAC5G,EACA,CAAC,EAAQ,EAAS,CAAC,CA4BtB,OA1BA,MAAsB,CACpB,GAAQ,CAER,IAAM,EAAW,IAAI,iBAAkB,GAAc,CAInD,CAHuB,EACpB,SAAS,CAAE,aAAY,kBAAmB,CAAC,GAAG,EAAY,GAAG,EAAa,CAAC,CAC3E,KAAM,GAAS,GAAW,SAAS,EAAK,CAAC,EACzB,GAAQ,EAC3B,CAMF,OAJA,EAAS,QAAQ,SAAS,KAAM,CAC9B,UAAW,GACX,QAAS,GACV,CAAC,KACW,EAAS,YAAY,EACjC,CAAC,EAAQ,EAAQ,EAAU,CAAC,CAE/B,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,EAAa,EAAO,EAAW,EAAI,EAGzD,EAAY,YAAc,cAE1B,IAAA,EAAe"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../src/index.ts"],"sourcesContent":["import React, { useEffect, useState, useRef, useCallback, useLayoutEffect } from 'react'\nimport { createPortal } 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 root?: Element\n children?: React.ReactElement | React.ReactElement[]\n onMount?: (anchor: Element, container: Element) => void\n onUnmount?: (anchor: Element, container: Element) => void\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\n/**\n * https://github.com/facebook/react/blob/d91d28c8ba6fe7c96e651f82fc47c9d5481bf5f9/packages/react-reconciler/src/ReactFiberHooks.js#L2792\n */\nconst setRef = <T>(ref: React.Ref<T> | undefined, value: T) => {\n if (typeof ref === 'function') {\n return ref(value)\n } else if (ref !== null && ref !== undefined) {\n ref.current = value\n }\n}\n\nconst mergeRef = <T extends Element | null>(...refs: (React.Ref<T> | undefined)[]) => {\n return (node: T) => {\n const cleanups = refs.map((ref) => setRef(ref, node))\n return () =>\n cleanups.forEach((cleanup, index) => (typeof cleanup === 'function' ? cleanup() : setRef(refs[index], null)))\n }\n}\n\nconst MagicPortal = ({\n anchor,\n position = 'append',\n root = document.body,\n children,\n onMount,\n onUnmount\n}: MagicPortalProps) => {\n const anchorRef = useRef<Element | null>(null)\n const [container, setContainer] = useState<Element | null>(null)\n\n const insertNode = useCallback(\n (node: Element | null) => {\n if (!node) {\n return\n }\n\n const anchorElement = anchorRef.current\n if (!anchorElement) {\n return\n }\n\n const containerElement =\n position === 'prepend' || position === 'append' ? anchorElement : anchorElement.parentElement\n if (!containerElement) {\n return\n }\n\n let alreadyPlaced = node.parentElement === containerElement\n\n if (alreadyPlaced) {\n switch (position) {\n case 'append':\n alreadyPlaced = node === containerElement.lastElementChild\n break\n case 'prepend':\n alreadyPlaced = node === containerElement.firstElementChild\n break\n case 'before':\n alreadyPlaced = node.nextElementSibling === anchorElement\n break\n case 'after':\n alreadyPlaced = node.previousElementSibling === anchorElement\n break\n }\n }\n\n if (!alreadyPlaced) {\n const positionMap = {\n before: 'beforebegin',\n prepend: 'afterbegin',\n append: 'beforeend',\n after: 'afterend'\n } as const\n anchorElement.insertAdjacentElement(positionMap[position], node)\n }\n },\n [position]\n )\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, insertNode)\n })\n })\n\n const update = useCallback(() => {\n anchorRef.current = resolveAnchor(anchor)\n setContainer(\n position === 'prepend' || position === 'append' ? anchorRef.current : (anchorRef.current?.parentElement ?? null)\n )\n }, [anchor, position])\n\n useLayoutEffect(() => {\n update()\n\n const observer = new MutationObserver((mutations) => {\n const isSelfMutation = mutations\n .flatMap(({ addedNodes, removedNodes }) => [...addedNodes, ...removedNodes])\n .some((node) => container?.contains(node))\n !isSelfMutation && update()\n })\n\n observer.observe(root, {\n childList: true,\n subtree: true\n })\n return () => observer.disconnect()\n }, [update, anchor, container, root])\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 && createPortal(nodes, container)\n}\n\nMagicPortal.displayName = 'MagicPortal'\n\nexport default MagicPortal\n"],"mappings":"8IAeA,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,EAOL,GAAa,EAA+B,IAAa,CAC7D,GAAI,OAAO,GAAQ,WACjB,OAAO,EAAI,EAAM,CACR,GAAQ,OACjB,EAAI,QAAU,IAIZ,GAAsC,GAAG,IACrC,GAAY,CAClB,IAAM,EAAW,EAAK,IAAK,GAAQ,EAAO,EAAK,EAAK,CAAC,CACrD,UACE,EAAS,SAAS,EAAS,IAAW,OAAO,GAAY,WAAa,GAAS,CAAG,EAAO,EAAK,GAAQ,KAAK,CAAE,EAI7G,GAAe,CACnB,SACA,WAAW,SACX,OAAO,SAAS,KAChB,WACA,UACA,eACsB,CACtB,IAAM,EAAY,EAAuB,KAAK,CACxC,CAAC,EAAW,GAAgB,EAAyB,KAAK,CAE1D,EAAa,EAChB,GAAyB,CACxB,GAAI,CAAC,EACH,OAGF,IAAM,EAAgB,EAAU,QAChC,GAAI,CAAC,EACH,OAGF,IAAM,EACJ,IAAa,WAAa,IAAa,SAAW,EAAgB,EAAc,cAClF,GAAI,CAAC,EACH,OAGF,IAAI,EAAgB,EAAK,gBAAkB,EAE3C,GAAI,EACF,OAAQ,EAAR,CACE,IAAK,SACH,EAAgB,IAAS,EAAiB,iBAC1C,MACF,IAAK,UACH,EAAgB,IAAS,EAAiB,kBAC1C,MACF,IAAK,SACH,EAAgB,EAAK,qBAAuB,EAC5C,MACF,IAAK,QACH,EAAgB,EAAK,yBAA2B,EAChD,MAID,GAOH,EAAc,sBANM,CAClB,OAAQ,cACR,QAAS,aACT,OAAQ,YACR,MAAO,WACR,CAC+C,GAAW,EAAK,EAGpE,CAAC,EAAS,CACX,CAEK,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,EAAa,EAAW,CACvC,CAAC,EACF,CAEI,EAAS,MAAkB,CAC/B,EAAU,QAAU,EAAc,EAAO,CACzC,EACE,IAAa,WAAa,IAAa,SAAW,EAAU,QAAW,EAAU,SAAS,eAAiB,KAC5G,EACA,CAAC,EAAQ,EAAS,CAAC,CA4BtB,OA1BA,MAAsB,CACpB,GAAQ,CAER,IAAM,EAAW,IAAI,iBAAkB,GAAc,CAInD,CAHuB,EACpB,SAAS,CAAE,aAAY,kBAAmB,CAAC,GAAG,EAAY,GAAG,EAAa,CAAC,CAC3E,KAAM,GAAS,GAAW,SAAS,EAAK,CAAC,EACzB,GAAQ,EAC3B,CAMF,OAJA,EAAS,QAAQ,EAAM,CACrB,UAAW,GACX,QAAS,GACV,CAAC,KACW,EAAS,YAAY,EACjC,CAAC,EAAQ,EAAQ,EAAW,EAAK,CAAC,CAErC,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,EAAa,EAAO,EAAU,EAGpD,EAAY,YAAc,cAE1B,IAAA,EAAe"}
@@ -1,12 +1,12 @@
1
1
  import globals from 'globals'
2
+ import type { Linter } from 'eslint'
2
3
  import pluginJs from '@eslint/js'
3
4
  import { defineConfig } from 'eslint/config'
4
5
  import tseslint from 'typescript-eslint'
5
6
  import prettierPlugin from 'eslint-plugin-prettier/recommended'
6
- import reactHooks from 'eslint-plugin-react-hooks'
7
- import reactRefresh from 'eslint-plugin-react-refresh'
7
+ import eslintReact from '@eslint-react/eslint-plugin'
8
8
 
9
- export default defineConfig([
9
+ const config: Linter.FlatConfig[] = defineConfig([
10
10
  {
11
11
  ignores: ['**/dist/*']
12
12
  },
@@ -18,14 +18,18 @@ export default defineConfig([
18
18
  }
19
19
  },
20
20
  pluginJs.configs.recommended,
21
- ...tseslint.configs.recommended,
21
+ tseslint.configs.recommended,
22
+ eslintReact.configs['recommended-typescript'],
22
23
  prettierPlugin,
23
- reactHooks.configs['recommended-latest'],
24
- reactRefresh.configs.vite,
25
24
  {
26
25
  rules: {
27
26
  '@typescript-eslint/no-explicit-any': 'off',
28
- '@typescript-eslint/no-unused-expressions': 'off'
27
+ '@typescript-eslint/no-unused-expressions': 'off',
28
+ '@eslint-react/no-children-map': 'off',
29
+ '@eslint-react/no-clone-element': 'off',
30
+ '@eslint-react/hooks-extra/no-direct-set-state-in-use-effect': 'off'
29
31
  }
30
32
  }
31
33
  ])
34
+
35
+ export default config
@@ -29,22 +29,21 @@
29
29
  },
30
30
  "homepage": "https://github.com/molvqingtai/react-magic-portal#readme",
31
31
  "devDependencies": {
32
- "@eslint/js": "^9.36.0",
33
- "@types/node": "^24.6.0",
34
- "@types/react": "^19.1.15",
35
- "@types/react-dom": "^19.1.9",
36
- "eslint": "^9.36.0",
32
+ "@eslint-react/eslint-plugin": "^2.0.6",
33
+ "@eslint/js": "^9.37.0",
34
+ "@types/node": "^24.7.1",
35
+ "@types/react": "^19.2.2",
36
+ "@types/react-dom": "^19.2.1",
37
+ "eslint": "^9.37.0",
37
38
  "eslint-config-prettier": "^10.1.8",
38
39
  "eslint-plugin-prettier": "^5.5.4",
39
- "eslint-plugin-react-hooks": "^5.2.0",
40
- "eslint-plugin-react-refresh": "^0.4.22",
41
40
  "globals": "^16.4.0",
42
41
  "prettier": "^3.6.2",
43
- "react": "^19.1.1",
44
- "react-dom": "^19.1.1",
45
- "tsdown": "^0.15.5",
46
- "typescript": "^5.9.2",
47
- "typescript-eslint": "^8.45.0"
42
+ "react": "^19.2.0",
43
+ "react-dom": "^19.2.0",
44
+ "tsdown": "^0.15.6",
45
+ "typescript": "^5.9.3",
46
+ "typescript-eslint": "^8.46.0"
48
47
  },
49
48
  "peerDependencies": {
50
49
  "react": ">=18.0.0",
@@ -4,10 +4,10 @@ import { createPortal } from 'react-dom'
4
4
  export interface MagicPortalProps {
5
5
  anchor: string | (() => Element | null) | Element | React.RefObject<Element | null> | null
6
6
  position?: 'append' | 'prepend' | 'before' | 'after'
7
+ root?: Element
7
8
  children?: React.ReactElement | React.ReactElement[]
8
9
  onMount?: (anchor: Element, container: Element) => void
9
10
  onUnmount?: (anchor: Element, container: Element) => void
10
- key?: React.Key
11
11
  }
12
12
 
13
13
  /**
@@ -62,25 +62,73 @@ const mergeRef = <T extends Element | null>(...refs: (React.Ref<T> | undefined)[
62
62
  }
63
63
  }
64
64
 
65
- const MagicPortal = ({ anchor, position = 'append', children, onMount, onUnmount, key }: MagicPortalProps) => {
65
+ const MagicPortal = ({
66
+ anchor,
67
+ position = 'append',
68
+ root = document.body,
69
+ children,
70
+ onMount,
71
+ onUnmount
72
+ }: MagicPortalProps) => {
66
73
  const anchorRef = useRef<Element | null>(null)
67
74
  const [container, setContainer] = useState<Element | null>(null)
68
75
 
69
- const nodes = React.Children.map(children, (item) => {
70
- if (!React.isValidElement(item)) {
71
- return null
72
- }
73
- const originalRef = getElementRef(item)
74
- return React.cloneElement(item as React.ReactElement<any>, {
75
- ref: mergeRef(originalRef, (node) => {
76
+ const insertNode = useCallback(
77
+ (node: Element | null) => {
78
+ if (!node) {
79
+ return
80
+ }
81
+
82
+ const anchorElement = anchorRef.current
83
+ if (!anchorElement) {
84
+ return
85
+ }
86
+
87
+ const containerElement =
88
+ position === 'prepend' || position === 'append' ? anchorElement : anchorElement.parentElement
89
+ if (!containerElement) {
90
+ return
91
+ }
92
+
93
+ let alreadyPlaced = node.parentElement === containerElement
94
+
95
+ if (alreadyPlaced) {
96
+ switch (position) {
97
+ case 'append':
98
+ alreadyPlaced = node === containerElement.lastElementChild
99
+ break
100
+ case 'prepend':
101
+ alreadyPlaced = node === containerElement.firstElementChild
102
+ break
103
+ case 'before':
104
+ alreadyPlaced = node.nextElementSibling === anchorElement
105
+ break
106
+ case 'after':
107
+ alreadyPlaced = node.previousElementSibling === anchorElement
108
+ break
109
+ }
110
+ }
111
+
112
+ if (!alreadyPlaced) {
76
113
  const positionMap = {
77
114
  before: 'beforebegin',
78
115
  prepend: 'afterbegin',
79
116
  append: 'beforeend',
80
117
  after: 'afterend'
81
118
  } as const
82
- node && anchorRef.current?.insertAdjacentElement(positionMap[position], node)
83
- })
119
+ anchorElement.insertAdjacentElement(positionMap[position], node)
120
+ }
121
+ },
122
+ [position]
123
+ )
124
+
125
+ const nodes = React.Children.map(children, (item) => {
126
+ if (!React.isValidElement(item)) {
127
+ return null
128
+ }
129
+ const originalRef = getElementRef(item)
130
+ return React.cloneElement(item as React.ReactElement<any>, {
131
+ ref: mergeRef(originalRef, insertNode)
84
132
  })
85
133
  })
86
134
 
@@ -101,12 +149,12 @@ const MagicPortal = ({ anchor, position = 'append', children, onMount, onUnmount
101
149
  !isSelfMutation && update()
102
150
  })
103
151
 
104
- observer.observe(document.body, {
152
+ observer.observe(root, {
105
153
  childList: true,
106
154
  subtree: true
107
155
  })
108
156
  return () => observer.disconnect()
109
- }, [update, anchor, container])
157
+ }, [update, anchor, container, root])
110
158
 
111
159
  useEffect(() => {
112
160
  if (container && anchorRef.current) {
@@ -117,7 +165,7 @@ const MagicPortal = ({ anchor, position = 'append', children, onMount, onUnmount
117
165
  }
118
166
  }, [onMount, onUnmount, container])
119
167
 
120
- return container && createPortal(nodes, container, key)
168
+ return container && createPortal(nodes, container)
121
169
  }
122
170
 
123
171
  MagicPortal.displayName = 'MagicPortal'
@@ -3,8 +3,7 @@ import pluginJs from '@eslint/js'
3
3
  import { defineConfig } from 'eslint/config'
4
4
  import tseslint from 'typescript-eslint'
5
5
  import prettierPlugin from 'eslint-plugin-prettier/recommended'
6
- import reactHooks from 'eslint-plugin-react-hooks'
7
- import reactRefresh from 'eslint-plugin-react-refresh'
6
+ import eslintReact from '@eslint-react/eslint-plugin'
8
7
 
9
8
  export default defineConfig([
10
9
  {
@@ -18,8 +17,7 @@ export default defineConfig([
18
17
  }
19
18
  },
20
19
  pluginJs.configs.recommended,
21
- ...tseslint.configs.recommended,
22
- prettierPlugin,
23
- reactHooks.configs['recommended-latest'],
24
- reactRefresh.configs.vite
20
+ tseslint.configs.recommended,
21
+ eslintReact.configs['recommended-typescript'],
22
+ prettierPlugin
25
23
  ])
@@ -11,22 +11,21 @@
11
11
  "check": "tsc --noEmit"
12
12
  },
13
13
  "dependencies": {
14
- "react": "^19.1.1",
15
- "react-dom": "^19.1.1",
14
+ "react": "^19.2.0",
15
+ "react-dom": "^19.2.0",
16
16
  "react-magic-portal": "workspace:*"
17
17
  },
18
18
  "devDependencies": {
19
- "@eslint/js": "^9.36.0",
20
- "@types/react": "^19.1.15",
21
- "@types/react-dom": "^19.1.9",
19
+ "@eslint-react/eslint-plugin": "^2.0.6",
20
+ "@eslint/js": "^9.37.0",
21
+ "@types/react": "^19.2.2",
22
+ "@types/react-dom": "^19.2.1",
22
23
  "@vitejs/plugin-react": "^5.0.4",
23
- "eslint": "^9.36.0",
24
+ "eslint": "^9.37.0",
24
25
  "eslint-plugin-prettier": "^5.5.4",
25
- "eslint-plugin-react-hooks": "^5.2.0",
26
- "eslint-plugin-react-refresh": "^0.4.22",
27
26
  "globals": "^16.4.0",
28
- "typescript": "~5.9.2",
29
- "typescript-eslint": "^8.45.0",
30
- "vite": "^7.1.7"
27
+ "typescript": "~5.9.3",
28
+ "typescript-eslint": "^8.46.0",
29
+ "vite": "^7.1.9"
31
30
  }
32
31
  }
@@ -27,7 +27,9 @@ function App() {
27
27
  )}
28
28
  </div>
29
29
  <div className="controls">
30
- <button onClick={() => setShowAnchor(!showAnchor)}>{showAnchor ? 'Hide Anchor' : 'Show Anchor'}</button>
30
+ <button type="button" onClick={() => setShowAnchor(!showAnchor)}>
31
+ {showAnchor ? 'Hide Anchor' : 'Show Anchor'}
32
+ </button>
31
33
  </div>
32
34
 
33
35
  {/* Target anchor examples */}