react-magic-portal 1.1.9 → 1.2.1

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.1](https://github.com/molvqingtai/react-magic-portal/compare/v1.2.0...v1.2.1) (2025-10-10)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * fix issue of text nodes not being updated & suppress React runtime warnings ([7307c2a](https://github.com/molvqingtai/react-magic-portal/commit/7307c2ad08be8a27a3e55522b0b9f14e501c61d2))
7
+
8
+ # [1.2.0](https://github.com/molvqingtai/react-magic-portal/compare/v1.1.9...v1.2.0) (2025-10-10)
9
+
10
+
11
+ ### Features
12
+
13
+ * add root prop to customize MutationObserver scope ([e27aeeb](https://github.com/molvqingtai/react-magic-portal/commit/e27aeeb70ae2b93f9f6ac80d25deb436ca125065))
14
+
15
+
16
+ ### Performance Improvements
17
+
18
+ * optimize node insertion with position check ([866947b](https://github.com/molvqingtai/react-magic-portal/commit/866947bba294d2887c30a9ce4670ecf6b5429b05))
19
+
1
20
  ## [1.1.9](https://github.com/molvqingtai/react-magic-portal/compare/v1.1.8...v1.1.9) (2025-10-09)
2
21
 
3
22
 
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.1",
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;;cAoEhE,WAlEG,EAAA;;IACI,MAAM;IAAA,QAAA;IAAA,IAAA;IAAA,QAAA;IAAA,OAAA;IAAA;EAAA,CAAA,EAwEhB,gBAxEgB,CAAA,EAwEA,KAAA,CAAA,WAxEA,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)=>e?t===`prepend`||t===`append`?e:e.parentElement:null,u=(e,t)=>{if(typeof e==`function`)return e(t);e!=null&&(e.current=t)},d=(...e)=>t=>{let n=e.map(e=>u(e,t));return()=>n.forEach((t,n)=>typeof t==`function`?t():u(e[n],null))},f=({anchor:u,position:f=`append`,root:p=document.body,children:m,onMount:h,onUnmount:g})=>{let _=i(null),[v,y]=a(null),b=t(e=>{if(!e)return;let t=_.current;if(!t)return;let n=l(t,f);if(!n)return;let r=!1;switch(f){case`append`:r=e.parentElement===n&&n.lastChild===e;break;case`prepend`:r=e.parentElement===n&&n.firstChild===e;break;case`before`:r=e.parentElement===n&&t.previousSibling===e;break;case`after`:r=e.parentElement===n&&t.nextSibling===e;break}r||t.insertAdjacentElement({before:`beforebegin`,prepend:`afterbegin`,append:`beforeend`,after:`afterend`}[f],e)},[f]),x=e.Children.map(m,t=>{if(!e.isValidElement(t))return null;let n=s(t);return e.cloneElement(t,{ref:d(n,b)})}),S=t(()=>{_.current=c(u);let e=l(_.current,f);e&&(e.__reactWarnedAboutChildrenConflict=!0),y(e)},[u,f]);return r(()=>{S();let e=new MutationObserver(e=>{!e.flatMap(({addedNodes:e,removedNodes:t})=>[...e,...t]).some(e=>v?.contains(e))&&S()});return e.observe(p,{childList:!0,subtree:!0}),()=>e.disconnect()},[S,u,v,p]),n(()=>{if(v&&_.current)return h?.(_.current,v),()=>{g?.(_.current,v)}},[h,g,v]),v?o(x,v):null};f.displayName=`MagicPortal`;var p=f;export{p 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\nconst resolveContainer = (anchor: Element | null, position: MagicPortalProps['position']): Element | null => {\n if (!anchor) {\n return null\n }\n\n return position === 'prepend' || position === 'append' ? anchor : anchor.parentElement\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 = resolveContainer(anchorElement, position)\n if (!containerElement) {\n return\n }\n\n let alreadyPlaced = false\n\n switch (position) {\n case 'append':\n alreadyPlaced = node.parentElement === containerElement && containerElement.lastChild === node\n break\n case 'prepend':\n alreadyPlaced = node.parentElement === containerElement && containerElement.firstChild === node\n break\n case 'before':\n alreadyPlaced = node.parentElement === containerElement && anchorElement.previousSibling === node\n break\n case 'after':\n alreadyPlaced = node.parentElement === containerElement && anchorElement.nextSibling === node\n break\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 const nextContainer = resolveContainer(anchorRef.current, position)\n /**\n * React 19 in DEV\n * Suppress DevTools warning from React runtime about conflicting container children.\n * @see https://github.com/facebook/react/blob/main/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js#L973\n */\n if (nextContainer) {\n ;(nextContainer as { __reactWarnedAboutChildrenConflict?: boolean }).__reactWarnedAboutChildrenConflict = true\n }\n\n setContainer(nextContainer)\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) : null\n}\n\nMagicPortal.displayName = 'MagicPortal'\n\nexport default MagicPortal\n"],"mappings":"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"}
@@ -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
@@ -42,6 +43,14 @@ const resolveAnchor = (anchor: MagicPortalProps['anchor']) => {
42
43
  }
43
44
  }
44
45
 
46
+ const resolveContainer = (anchor: Element | null, position: MagicPortalProps['position']): Element | null => {
47
+ if (!anchor) {
48
+ return null
49
+ }
50
+
51
+ return position === 'prepend' || position === 'append' ? anchor : anchor.parentElement
52
+ }
53
+
45
54
  /**
46
55
  * https://github.com/facebook/react/blob/d91d28c8ba6fe7c96e651f82fc47c9d5481bf5f9/packages/react-reconciler/src/ReactFiberHooks.js#L2792
47
56
  */
@@ -61,33 +70,86 @@ const mergeRef = <T extends Element | null>(...refs: (React.Ref<T> | undefined)[
61
70
  }
62
71
  }
63
72
 
64
- const MagicPortal = ({ anchor, position = 'append', children, onMount, onUnmount }: MagicPortalProps) => {
73
+ const MagicPortal = ({
74
+ anchor,
75
+ position = 'append',
76
+ root = document.body,
77
+ children,
78
+ onMount,
79
+ onUnmount
80
+ }: MagicPortalProps) => {
65
81
  const anchorRef = useRef<Element | null>(null)
66
82
  const [container, setContainer] = useState<Element | null>(null)
67
83
 
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) => {
84
+ const insertNode = useCallback(
85
+ (node: Element | null) => {
86
+ if (!node) {
87
+ return
88
+ }
89
+
90
+ const anchorElement = anchorRef.current
91
+ if (!anchorElement) {
92
+ return
93
+ }
94
+
95
+ const containerElement = resolveContainer(anchorElement, position)
96
+ if (!containerElement) {
97
+ return
98
+ }
99
+
100
+ let alreadyPlaced = false
101
+
102
+ switch (position) {
103
+ case 'append':
104
+ alreadyPlaced = node.parentElement === containerElement && containerElement.lastChild === node
105
+ break
106
+ case 'prepend':
107
+ alreadyPlaced = node.parentElement === containerElement && containerElement.firstChild === node
108
+ break
109
+ case 'before':
110
+ alreadyPlaced = node.parentElement === containerElement && anchorElement.previousSibling === node
111
+ break
112
+ case 'after':
113
+ alreadyPlaced = node.parentElement === containerElement && anchorElement.nextSibling === node
114
+ break
115
+ }
116
+
117
+ if (!alreadyPlaced) {
75
118
  const positionMap = {
76
119
  before: 'beforebegin',
77
120
  prepend: 'afterbegin',
78
121
  append: 'beforeend',
79
122
  after: 'afterend'
80
123
  } as const
81
- node && anchorRef.current?.insertAdjacentElement(positionMap[position], node)
82
- })
124
+ anchorElement.insertAdjacentElement(positionMap[position], node)
125
+ }
126
+ },
127
+ [position]
128
+ )
129
+
130
+ const nodes = React.Children.map(children, (item) => {
131
+ if (!React.isValidElement(item)) {
132
+ return null
133
+ }
134
+ const originalRef = getElementRef(item)
135
+ return React.cloneElement(item as React.ReactElement<any>, {
136
+ ref: mergeRef(originalRef, insertNode)
83
137
  })
84
138
  })
85
139
 
86
140
  const update = useCallback(() => {
87
141
  anchorRef.current = resolveAnchor(anchor)
88
- setContainer(
89
- position === 'prepend' || position === 'append' ? anchorRef.current : (anchorRef.current?.parentElement ?? null)
90
- )
142
+ const nextContainer = resolveContainer(anchorRef.current, position)
143
+ /**
144
+ * React 19 in DEV
145
+ * Suppress DevTools warning from React runtime about conflicting container children.
146
+ * @see https://github.com/facebook/react/blob/main/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js#L973
147
+ */
148
+ if (nextContainer) {
149
+ ;(nextContainer as { __reactWarnedAboutChildrenConflict?: boolean }).__reactWarnedAboutChildrenConflict = true
150
+ }
151
+
152
+ setContainer(nextContainer)
91
153
  }, [anchor, position])
92
154
 
93
155
  useLayoutEffect(() => {
@@ -100,12 +162,12 @@ const MagicPortal = ({ anchor, position = 'append', children, onMount, onUnmount
100
162
  !isSelfMutation && update()
101
163
  })
102
164
 
103
- observer.observe(document.body, {
165
+ observer.observe(root, {
104
166
  childList: true,
105
167
  subtree: true
106
168
  })
107
169
  return () => observer.disconnect()
108
- }, [update, anchor, container])
170
+ }, [update, anchor, container, root])
109
171
 
110
172
  useEffect(() => {
111
173
  if (container && anchorRef.current) {
@@ -116,7 +178,7 @@ const MagicPortal = ({ anchor, position = 'append', children, onMount, onUnmount
116
178
  }
117
179
  }, [onMount, onUnmount, container])
118
180
 
119
- return container && createPortal(nodes, container)
181
+ return container ? createPortal(nodes, container) : null
120
182
  }
121
183
 
122
184
  MagicPortal.displayName = 'MagicPortal'