react-magic-portal 1.2.1 → 1.3.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,10 @@
1
+ # [1.3.0](https://github.com/molvqingtai/react-magic-portal/compare/v1.2.1...v1.3.0) (2025-10-10)
2
+
3
+
4
+ ### Features
5
+
6
+ * enforce single child ([8fe438d](https://github.com/molvqingtai/react-magic-portal/commit/8fe438d149a04d3070fe8eed725d7932492da01c))
7
+
1
8
  ## [1.2.1](https://github.com/molvqingtai/react-magic-portal/compare/v1.2.0...v1.2.1) (2025-10-10)
2
9
 
3
10
 
package/README.md CHANGED
@@ -102,7 +102,7 @@ function App() {
102
102
  | `anchor` | `string \| (() => Element \| null) \| Element \| React.RefObject<Element \| null> \| null` | **Required** | The target element where the portal content will be rendered |
103
103
  | `position` | `'append' \| 'prepend' \| 'before' \| 'after'` | `'append'` | Position relative to the anchor element |
104
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 |
105
+ | `children` | `React.ReactElement \| null` | `undefined` | A single React element to render in the portal (does not support Fragment) |
106
106
  | `onMount` | `(anchor: Element, container: Element) => void` | `undefined` | Callback fired when the portal is mounted |
107
107
  | `onUnmount` | `(anchor: Element, container: Element) => void` | `undefined` | Callback fired when the portal is unmounted |
108
108
 
@@ -382,28 +382,6 @@ describe('MagicPortal', () => {
382
382
  expect(refCallback).toHaveBeenLastCalledWith(null)
383
383
  })
384
384
 
385
- it('should handle multiple content elements with refs', () => {
386
- const ref1 = vi.fn()
387
- const ref2 = vi.fn()
388
- const anchor = document.createElement('div')
389
- anchor.id = 'multi-ref-anchor'
390
- document.body.appendChild(anchor)
391
-
392
- render(
393
- <MagicPortal anchor="#multi-ref-anchor">
394
- <div ref={ref1} data-testid="content-1">
395
- Content 1
396
- </div>
397
- <span ref={ref2} data-testid="content-2">
398
- Content 2
399
- </span>
400
- </MagicPortal>
401
- )
402
-
403
- expect(ref1).toHaveBeenCalledWith(expect.any(HTMLDivElement))
404
- expect(ref2).toHaveBeenCalledWith(expect.any(HTMLSpanElement))
405
- })
406
-
407
385
  it('should maintain content refs across position changes', async () => {
408
386
  const contentRef = vi.fn()
409
387
  const anchor = document.createElement('div')
@@ -466,43 +444,26 @@ describe('MagicPortal', () => {
466
444
  expect(refCalls[0]?.textContent).toBe('Click me')
467
445
  })
468
446
 
469
- it('should handle single element content', () => {
447
+ it('should log error when multiple children are provided', () => {
448
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
470
449
  const anchor = document.createElement('div')
471
- anchor.id = 'single-content-anchor'
450
+ anchor.id = 'multiple-children-anchor'
472
451
  document.body.appendChild(anchor)
473
452
 
474
453
  render(
475
- <MagicPortal anchor="#single-content-anchor">
476
- <span data-testid="single-content">Just content</span>
454
+ // @ts-expect-error - intentionally passing multiple children to assert runtime guard
455
+ <MagicPortal anchor="#multiple-children-anchor">
456
+ <span data-testid="first-element">First element</span>
457
+ <span data-testid="second-element">Second element</span>
477
458
  </MagicPortal>
478
459
  )
479
460
 
480
- expect(screen.getByTestId('single-content')).toBeTruthy()
481
- expect(anchor.contains(screen.getByTestId('single-content'))).toBe(true)
482
- })
483
-
484
- it('should handle multiple element content with refs', () => {
485
- const ref1 = vi.fn()
486
- const ref2 = vi.fn()
487
- const anchor = document.createElement('div')
488
- anchor.id = 'multiple-content-anchor'
489
- document.body.appendChild(anchor)
490
-
491
- render(
492
- <MagicPortal anchor="#multiple-content-anchor">
493
- <span ref={ref1} data-testid="first-element">
494
- First element
495
- </span>
496
- <div ref={ref2} data-testid="second-element">
497
- Second element
498
- </div>
499
- </MagicPortal>
461
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
462
+ '[react-magic-portal] Multiple children are not supported, expected to receive a single React element child.'
500
463
  )
464
+ expect(anchor.children.length).toBe(0)
501
465
 
502
- expect(ref1).toHaveBeenCalledWith(expect.any(HTMLSpanElement))
503
- expect(ref2).toHaveBeenCalledWith(expect.any(HTMLDivElement))
504
- expect(screen.getByTestId('first-element')).toBeTruthy()
505
- expect(screen.getByTestId('second-element')).toBeTruthy()
466
+ consoleErrorSpy.mockRestore()
506
467
  })
507
468
 
508
469
  it('should handle nested refs correctly', () => {
@@ -707,61 +668,56 @@ describe('MagicPortal', () => {
707
668
  })
708
669
 
709
670
  describe('Text Node Handling', () => {
710
- it('should not render pure text content', () => {
671
+ it('should not render Fragment children', () => {
672
+ const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
711
673
  const anchor = document.createElement('div')
712
- anchor.id = 'text-only-anchor'
674
+ anchor.id = 'fragment-anchor'
713
675
  document.body.appendChild(anchor)
714
676
 
715
677
  render(
716
- // @ts-expect-error - testing that text nodes are not rendered
717
- <MagicPortal anchor="#text-only-anchor">Just plain text</MagicPortal>
678
+ <MagicPortal anchor="#fragment-anchor">
679
+ <>
680
+ <div>Child 1</div>
681
+ <div>Child 2</div>
682
+ </>
683
+ </MagicPortal>
718
684
  )
719
685
 
720
- // Anchor should remain empty since text nodes are not rendered
686
+ // Should log error about Fragment not being supported
687
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
688
+ '[react-magic-portal] Fragment children are not supported, expected to receive a single React element child.'
689
+ )
690
+
691
+ // Anchor should remain empty since Fragment is not supported
721
692
  expect(anchor.textContent).toBe('')
722
693
  expect(anchor.children.length).toBe(0)
694
+
695
+ consoleErrorSpy.mockRestore()
723
696
  })
724
697
 
725
- it('should not render mixed text and element content (only elements)', () => {
698
+ it('should not render pure text content', () => {
726
699
  const anchor = document.createElement('div')
727
- anchor.id = 'mixed-text-anchor'
700
+ anchor.id = 'text-only-anchor'
728
701
  document.body.appendChild(anchor)
729
702
 
730
703
  render(
731
- // @ts-expect-error - testing mixed content behavior
732
- <MagicPortal anchor="#mixed-text-anchor">
733
- Some text before
734
- {/* @ts-expect-error - testing mixed content behavior*/}
735
- <div data-testid="element-content">Element</div>
736
- Some text after
737
- </MagicPortal>
704
+ // @ts-expect-error - testing that text nodes are not rendered
705
+ <MagicPortal anchor="#text-only-anchor">Just plain text</MagicPortal>
738
706
  )
739
707
 
740
- // Only the div element should be rendered, text nodes should be ignored
741
- expect(screen.getByTestId('element-content')).toBeTruthy()
742
- expect(anchor.contains(screen.getByTestId('element-content'))).toBe(true)
743
-
744
- // Text content should only be from the div element, not the text nodes
745
- expect(anchor.textContent?.trim()).toBe('Element')
746
- expect(anchor.textContent).not.toContain('Some text before')
747
- expect(anchor.textContent).not.toContain('Some text after')
708
+ // Anchor should remain empty since text nodes are not rendered
709
+ expect(anchor.textContent).toBe('')
710
+ expect(anchor.children.length).toBe(0)
748
711
  })
749
712
 
750
- it('should not render number or boolean primitives', () => {
713
+ it('should not render null children', () => {
751
714
  const anchor = document.createElement('div')
752
- anchor.id = 'primitive-anchor'
715
+ anchor.id = 'null-anchor'
753
716
  document.body.appendChild(anchor)
754
717
 
755
- render(
756
- <MagicPortal anchor="#primitive-anchor">
757
- {42 as any}
758
- {true as any}
759
- {null as any}
760
- {undefined as any}
761
- </MagicPortal>
762
- )
718
+ render(<MagicPortal anchor="#null-anchor">{null}</MagicPortal>)
763
719
 
764
- // Anchor should remain empty since primitives are not rendered
720
+ // Anchor should remain empty since null is not rendered
765
721
  expect(anchor.textContent).toBe('')
766
722
  expect(anchor.children.length).toBe(0)
767
723
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-magic-portal",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "React Portal with dynamic mounting support",
5
5
  "main": "packages/component/dist/index.js",
6
6
  "type": "module",
@@ -5,7 +5,7 @@ interface MagicPortalProps {
5
5
  anchor: string | (() => Element | null) | Element | React.RefObject<Element | null> | null;
6
6
  position?: 'append' | 'prepend' | 'before' | 'after';
7
7
  root?: Element;
8
- children?: React.ReactElement | React.ReactElement[];
8
+ children?: React.ReactElement | null;
9
9
  onMount?: (anchor: Element, container: Element) => void;
10
10
  onUnmount?: (anchor: Element, container: Element) => void;
11
11
  }
@@ -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,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
+ {"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,GAAA,IAAA;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;aACE,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)=>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};
1
+ import e,{useCallback as t,useEffect as n,useLayoutEffect as r,useMemo as i,useRef as a,useState as o}from"react";import{createPortal as s}from"react-dom";const c=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)},l=e=>typeof e==`string`?document.querySelector(e):typeof e==`function`?e():e&&`current`in e?e.current:e,u=(e,t)=>e?t===`prepend`||t===`append`?e:e.parentElement:null,d=(e,t)=>{if(typeof e==`function`)return e(t);e!=null&&(e.current=t)},f=(...e)=>t=>{let n=e.map(e=>d(e,t));return()=>n.forEach((t,n)=>typeof t==`function`?t():d(e[n],null))},p=({anchor:d,position:p=`append`,root:m=document.body,children:h,onMount:g,onUnmount:_})=>{let v=a(null),[y,b]=o(null),x=t(e=>{if(!e)return;let t=v.current;if(!t)return;let n=u(t,p);if(!n)return;let r=!1;switch(p){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`}[p],e)},[p]),S=i(()=>{if(e.Children.count(h)>1)return console.error(`[react-magic-portal] Multiple children are not supported, expected to receive a single React element child.`),null;if(!e.isValidElement(h))return null;if(h.type===e.Fragment)return console.error(`[react-magic-portal] Fragment children are not supported, expected to receive a single React element child.`),null;let t=c(h);return e.cloneElement(h,{ref:f(t,x)})},[h,x]),C=t(()=>{v.current=l(d);let e=u(v.current,p);e&&(e.__reactWarnedAboutChildrenConflict=!0),b(e)},[d,p]);return r(()=>{C();let e=new MutationObserver(e=>{!e.flatMap(({addedNodes:e,removedNodes:t})=>[...e,...t]).some(e=>y?.contains(e))&&C()});return e.observe(m,{childList:!0,subtree:!0}),()=>e.disconnect()},[C,d,y,m]),n(()=>{if(y&&v.current)return g?.(v.current,y),()=>{_?.(v.current,y)}},[g,_,y]),y&&S?s(S,y):null};p.displayName=`MagicPortal`;var m=p;export{m 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 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":"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,EAIL,GAAoB,EAAwB,IAC3C,EAIE,IAAa,WAAa,IAAa,SAAW,EAAS,EAAO,cAHhE,KASL,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,EAAmB,EAAiB,EAAe,EAAS,CAClE,GAAI,CAAC,EACH,OAGF,IAAI,EAAgB,GAEpB,OAAQ,EAAR,CACE,IAAK,SACH,EAAgB,EAAK,gBAAkB,GAAoB,EAAiB,YAAc,EAC1F,MACF,IAAK,UACH,EAAgB,EAAK,gBAAkB,GAAoB,EAAiB,aAAe,EAC3F,MACF,IAAK,SACH,EAAgB,EAAK,gBAAkB,GAAoB,EAAc,kBAAoB,EAC7F,MACF,IAAK,QACH,EAAgB,EAAK,gBAAkB,GAAoB,EAAc,cAAgB,EACzF,MAGC,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,IAAM,EAAgB,EAAiB,EAAU,QAAS,EAAS,CAM/D,IACA,EAAmE,mCAAqC,IAG5G,EAAa,EAAc,EAC1B,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,EAAY,EAAa,EAAO,EAAU,CAAG,MAGtD,EAAY,YAAc,cAE1B,IAAA,EAAe"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../src/index.ts"],"sourcesContent":["import React, { useEffect, useState, useRef, useCallback, useLayoutEffect, useMemo } 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 | null\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 child = useMemo(() => {\n if (React.Children.count(children) > 1) {\n console.error(\n '[react-magic-portal] Multiple children are not supported, expected to receive a single React element child.'\n )\n return null\n }\n\n if (!React.isValidElement(children)) {\n return null\n }\n\n if (children.type === React.Fragment) {\n console.error(\n '[react-magic-portal] Fragment children are not supported, expected to receive a single React element child.'\n )\n return null\n }\n\n const originalRef = getElementRef(children)\n return React.cloneElement(children as React.ReactElement<any>, {\n ref: mergeRef(originalRef, insertNode)\n })\n }, [children, insertNode])\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 && child ? createPortal(child, container) : null\n}\n\nMagicPortal.displayName = 'MagicPortal'\n\nexport default MagicPortal\n"],"mappings":"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"}
@@ -27,6 +27,8 @@ const config: Linter.FlatConfig[] = defineConfig([
27
27
  '@typescript-eslint/no-unused-expressions': 'off',
28
28
  '@eslint-react/no-children-map': 'off',
29
29
  '@eslint-react/no-clone-element': 'off',
30
+ '@eslint-react/no-children-only': 'off',
31
+ '@eslint-react/no-children-count': 'off',
30
32
  '@eslint-react/hooks-extra/no-direct-set-state-in-use-effect': 'off'
31
33
  }
32
34
  }
@@ -1,11 +1,11 @@
1
- import React, { useEffect, useState, useRef, useCallback, useLayoutEffect } from 'react'
1
+ import React, { useEffect, useState, useRef, useCallback, useLayoutEffect, useMemo } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
3
 
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
7
  root?: Element
8
- children?: React.ReactElement | React.ReactElement[]
8
+ children?: React.ReactElement | null
9
9
  onMount?: (anchor: Element, container: Element) => void
10
10
  onUnmount?: (anchor: Element, container: Element) => void
11
11
  }
@@ -127,15 +127,30 @@ const MagicPortal = ({
127
127
  [position]
128
128
  )
129
129
 
130
- const nodes = React.Children.map(children, (item) => {
131
- if (!React.isValidElement(item)) {
130
+ const child = useMemo(() => {
131
+ if (React.Children.count(children) > 1) {
132
+ console.error(
133
+ '[react-magic-portal] Multiple children are not supported, expected to receive a single React element child.'
134
+ )
132
135
  return null
133
136
  }
134
- const originalRef = getElementRef(item)
135
- return React.cloneElement(item as React.ReactElement<any>, {
137
+
138
+ if (!React.isValidElement(children)) {
139
+ return null
140
+ }
141
+
142
+ if (children.type === React.Fragment) {
143
+ console.error(
144
+ '[react-magic-portal] Fragment children are not supported, expected to receive a single React element child.'
145
+ )
146
+ return null
147
+ }
148
+
149
+ const originalRef = getElementRef(children)
150
+ return React.cloneElement(children as React.ReactElement<any>, {
136
151
  ref: mergeRef(originalRef, insertNode)
137
152
  })
138
- })
153
+ }, [children, insertNode])
139
154
 
140
155
  const update = useCallback(() => {
141
156
  anchorRef.current = resolveAnchor(anchor)
@@ -178,7 +193,7 @@ const MagicPortal = ({
178
193
  }
179
194
  }, [onMount, onUnmount, container])
180
195
 
181
- return container ? createPortal(nodes, container) : null
196
+ return container && child ? createPortal(child, container) : null
182
197
  }
183
198
 
184
199
  MagicPortal.displayName = 'MagicPortal'