react-magic-portal 1.1.9 → 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,15 @@
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
+
1
13
  ## [1.1.9](https://github.com/molvqingtai/react-magic-portal/compare/v1.1.8...v1.1.9) (2025-10-09)
2
14
 
3
15
 
package/README.md CHANGED
@@ -97,13 +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 |
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 |
107
108
 
108
109
  ### Anchor Types
109
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.9",
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,6 +4,7 @@ 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;
@@ -12,6 +13,7 @@ declare const MagicPortal: {
12
13
  ({
13
14
  anchor,
14
15
  position,
16
+ root,
15
17
  children,
16
18
  onMount,
17
19
  onUnmount
@@ -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;;cA2DtC,WA3DsD,EAAA;;IAE/C,MAAM;IAAA,QAAA;IAAA,QAAA;IAAA,OAAA;IAAA;EAAA,CAAA,EAyDiE,gBAzDjE,CAAA,EAyDiF,KAAA,CAAA,WAzDjF,GAAA,IAAA;aAAqB,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})=>{let h=i(null),[g,_]=a(null),v=e.Children.map(f,t=>{if(!e.isValidElement(t))return null;let n=s(t);return e.cloneElement(t,{ref:u(n,e=>{e&&h.current?.insertAdjacentElement({before:`beforebegin`,prepend:`afterbegin`,append:`beforeend`,after:`afterend`}[d],e)})})}),y=t(()=>{h.current=c(l),_(d===`prepend`||d===`append`?h.current:h.current?.parentElement??null)},[l,d]);return r(()=>{y();let e=new MutationObserver(e=>{!e.flatMap(({addedNodes:e,removedNodes:t})=>[...e,...t]).some(e=>g?.contains(e))&&y()});return e.observe(document.body,{childList:!0,subtree:!0}),()=>e.disconnect()},[y,l,g]),n(()=>{if(g&&h.current)return p?.(h.current,g),()=>{m?.(h.current,g)}},[p,m,g]),g&&o(v,g)};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}\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 }: 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)\n}\n\nMagicPortal.displayName = 'MagicPortal'\n\nexport default MagicPortal\n"],"mappings":"8IAcA,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,eAAkC,CACvG,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,EAAU,EAGpD,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,6 +4,7 @@ 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
@@ -61,25 +62,73 @@ const mergeRef = <T extends Element | null>(...refs: (React.Ref<T> | undefined)[
61
62
  }
62
63
  }
63
64
 
64
- const MagicPortal = ({ anchor, position = 'append', children, onMount, onUnmount }: MagicPortalProps) => {
65
+ const MagicPortal = ({
66
+ anchor,
67
+ position = 'append',
68
+ root = document.body,
69
+ children,
70
+ onMount,
71
+ onUnmount
72
+ }: MagicPortalProps) => {
65
73
  const anchorRef = useRef<Element | null>(null)
66
74
  const [container, setContainer] = useState<Element | null>(null)
67
75
 
68
- const nodes = React.Children.map(children, (item) => {
69
- if (!React.isValidElement(item)) {
70
- return null
71
- }
72
- const originalRef = getElementRef(item)
73
- return React.cloneElement(item as React.ReactElement<any>, {
74
- 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) {
75
113
  const positionMap = {
76
114
  before: 'beforebegin',
77
115
  prepend: 'afterbegin',
78
116
  append: 'beforeend',
79
117
  after: 'afterend'
80
118
  } as const
81
- node && anchorRef.current?.insertAdjacentElement(positionMap[position], node)
82
- })
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)
83
132
  })
84
133
  })
85
134
 
@@ -100,12 +149,12 @@ const MagicPortal = ({ anchor, position = 'append', children, onMount, onUnmount
100
149
  !isSelfMutation && update()
101
150
  })
102
151
 
103
- observer.observe(document.body, {
152
+ observer.observe(root, {
104
153
  childList: true,
105
154
  subtree: true
106
155
  })
107
156
  return () => observer.disconnect()
108
- }, [update, anchor, container])
157
+ }, [update, anchor, container, root])
109
158
 
110
159
  useEffect(() => {
111
160
  if (container && anchorRef.current) {
@@ -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 */}