@ionic/react 9.0.1-nightly.20260824 → 9.0.1-nightly.20260826

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/dist/index.js CHANGED
@@ -1395,6 +1395,72 @@ const detachEvent = (node, eventName) => {
1395
1395
  * React event-delegation root.
1396
1396
  */
1397
1397
  const NestedOverlayContext = React.createContext(false);
1398
+ /**
1399
+ * React skips `componentWillUnmount` when it destroys a subtree it had already
1400
+ * hidden, so a hidden overlay watches its own marker leave the document. One
1401
+ * observer serves every watcher, since each one allocates its own records for
1402
+ * a whole root.
1403
+ *
1404
+ * This all assumes React hides with `display: none` rather than detaching, so
1405
+ * the marker survives a hide. True today, but an implementation detail.
1406
+ */
1407
+ const destroyWatches = new Set();
1408
+ let destroyObserver;
1409
+ const stopDestroyObserverIfIdle = () => {
1410
+ if (destroyWatches.size === 0) {
1411
+ destroyObserver?.disconnect();
1412
+ destroyObserver = undefined;
1413
+ }
1414
+ };
1415
+ const watchForDestroy = (marker, onDestroy) => {
1416
+ if (typeof MutationObserver === 'undefined') {
1417
+ return undefined;
1418
+ }
1419
+ const watch = { marker, onDestroy };
1420
+ destroyWatches.add(watch);
1421
+ if (destroyObserver === undefined) {
1422
+ destroyObserver = new MutationObserver(() => {
1423
+ try {
1424
+ for (const candidate of Array.from(destroyWatches)) {
1425
+ if (candidate.marker.isConnected) {
1426
+ continue;
1427
+ }
1428
+ destroyWatches.delete(candidate);
1429
+ // `detachProps` assigns arbitrary props onto a custom element, so a
1430
+ // throwing setter would otherwise strand every remaining overlay.
1431
+ try {
1432
+ candidate.onDestroy();
1433
+ }
1434
+ catch (error) {
1435
+ console.error(error);
1436
+ }
1437
+ }
1438
+ }
1439
+ finally {
1440
+ stopDestroyObserverIfIdle();
1441
+ }
1442
+ });
1443
+ }
1444
+ /**
1445
+ * React usually removes the marker's own parent, and a `childList` observer
1446
+ * never fires for its own removal, so watch from the root down. Removing a
1447
+ * shadow host disconnects the marker without mutating anything inside its
1448
+ * root, so walk up the host chain too.
1449
+ */
1450
+ for (let root = marker.getRootNode(); root !== null;) {
1451
+ destroyObserver.observe(root, { childList: true, subtree: true });
1452
+ const { host } = root;
1453
+ root = host === undefined ? null : host.getRootNode();
1454
+ }
1455
+ return watch;
1456
+ };
1457
+ const unwatchForDestroy = (watch) => {
1458
+ if (watch === undefined) {
1459
+ return;
1460
+ }
1461
+ destroyWatches.delete(watch);
1462
+ stopDestroyObserverIfIdle();
1463
+ };
1398
1464
  const createInlineOverlayComponent = (tagName, defineCustomElement, hasDelegateHost) => {
1399
1465
  if (defineCustomElement) {
1400
1466
  defineCustomElement();
@@ -1406,7 +1472,18 @@ const createInlineOverlayComponent = (tagName, defineCustomElement, hasDelegateH
1406
1472
  markerRef;
1407
1473
  stableMergedRefs;
1408
1474
  portalTarget;
1409
- isUnmounted = false;
1475
+ // Bumped on every mount, so a deferred teardown can tell it was revealed.
1476
+ mountCount = 0;
1477
+ hiddenDestroyWatch;
1478
+ // Where a relocated portaled host sat before `componentWillUnmount` moved
1479
+ // it back into `portalTarget`, so a reveal can put it back.
1480
+ relocatedPortalHost;
1481
+ // React nulls the refs for as long as it keeps this subtree hidden, and a
1482
+ // dismiss can land in that window, so `handleDidDismiss` falls back to these.
1483
+ nodesAtUnmount = null;
1484
+ // Core clears `cachedOriginalParent` when the parent is removed, so the
1485
+ // redirect must not re-run on a reveal and put the reference back.
1486
+ hasRedirectedOriginalParent = false;
1410
1487
  constructor(props) {
1411
1488
  super(props);
1412
1489
  // Create a local ref to to attach props to the wrapped element.
@@ -1417,8 +1494,6 @@ const createInlineOverlayComponent = (tagName, defineCustomElement, hasDelegateH
1417
1494
  this.state = { isOpen: false };
1418
1495
  // Create a local ref to the inner child element.
1419
1496
  this.wrapperRef = React.createRef();
1420
- // Marker stays at the JSX location so we can recover the immediate
1421
- // JSX parent after the overlay has been portaled to ion-app.
1422
1497
  this.markerRef = React.createRef();
1423
1498
  /**
1424
1499
  * Resolve the portal target to the same container CoreDelegate
@@ -1430,10 +1505,11 @@ const createInlineOverlayComponent = (tagName, defineCustomElement, hasDelegateH
1430
1505
  this.portalTarget = typeof document !== 'undefined' ? document.querySelector('ion-app') || document.body : null;
1431
1506
  }
1432
1507
  componentDidMount() {
1433
- // Reset for React 18 StrictMode: the dev-mode unmount/remount cycle
1434
- // re-uses this instance and leaves the flag set from the prior
1435
- // componentWillUnmount.
1436
- this.isUnmounted = false;
1508
+ this.mountCount++;
1509
+ this.nodesAtUnmount = null;
1510
+ // React is showing this subtree again, so cancel the hide's teardown.
1511
+ this.stopWatchingForDestroy();
1512
+ this.restoreRelocatedPortalHost();
1437
1513
  this.componentDidUpdate(this.props);
1438
1514
  this.ref.current?.addEventListener('ionMount', this.handleIonMount);
1439
1515
  this.ref.current?.addEventListener('willPresent', this.handleWillPresent);
@@ -1444,14 +1520,23 @@ const createInlineOverlayComponent = (tagName, defineCustomElement, hasDelegateH
1444
1520
  * child-route passthrough, parent-removal auto-dismiss) walk up
1445
1521
  * from `cachedOriginalParent` to find the enclosing `.ion-page`,
1446
1522
  * so we redirect it at the marker's JSX parent.
1523
+ *
1524
+ * Nested overlays never portal, so they already cached their
1525
+ * `<template>` at the JSX position and resolve the right `.ion-page`.
1447
1526
  */
1448
1527
  const overlay = this.ref.current;
1449
- if (overlay) {
1528
+ const generation = this.mountCount;
1529
+ if (overlay && !this.props.isNested && !this.hasRedirectedOriginalParent) {
1450
1530
  componentOnReady(overlay, () => {
1451
- if (this.isUnmounted)
1452
- return;
1531
+ // Stale: a newer mount owns this, or React nulled the marker ref.
1532
+ // Leave the latch open so the reveal can try again.
1453
1533
  const markerParent = this.markerRef.current?.parentElement ?? null;
1454
- if (markerParent && markerParent !== this.portalTarget) {
1534
+ if (this.mountCount !== generation || markerParent === null) {
1535
+ return;
1536
+ }
1537
+ // Reached the JSX parent, so close the latch against later reveals.
1538
+ this.hasRedirectedOriginalParent = true;
1539
+ if (markerParent !== this.portalTarget) {
1455
1540
  overlay.cachedOriginalParent = markerParent;
1456
1541
  }
1457
1542
  });
@@ -1469,43 +1554,80 @@ const createInlineOverlayComponent = (tagName, defineCustomElement, hasDelegateH
1469
1554
  attachProps(node, cProps, prevProps);
1470
1555
  }
1471
1556
  componentWillUnmount() {
1472
- this.isUnmounted = true;
1473
1557
  const node = this.ref.current;
1474
1558
  if (!node) {
1475
1559
  return;
1476
1560
  }
1561
+ this.nodesAtUnmount = { host: node, wrapper: this.wrapperRef.current };
1477
1562
  /**
1478
- * CoreDelegate (or user code in onWillPresent) can move the overlay out
1479
- * of where React rendered it. React's unmount only removes the node from
1480
- * its original React location, so we recover a relocated host here,
1481
- * regardless of open state.
1482
- *
1483
- * We can't gate this on `isOpen`: the overlay can be moved before it
1484
- * finishes presenting (e.g. the React 18 StrictMode mount/unmount cycle,
1485
- * where the present events that flip `isOpen` haven't fired yet), which
1486
- * would orphan the relocated host in the DOM. It also has to run
1487
- * synchronously here, since React's portal `removeChild` runs after
1488
- * `componentWillUnmount` returns and needs the host where it expects it.
1563
+ * CoreDelegate (or user code in onWillPresent) can move a portaled
1564
+ * overlay out of `portalTarget`. React's portal `removeChild` runs as
1565
+ * soon as this returns and needs the host where it left it, so this has
1566
+ * to stay synchronous, before we know a hide from a destroy.
1489
1567
  */
1490
- if (node.isConnected) {
1491
- if (this.props.isNested) {
1492
- /**
1493
- * Nested overlays render inline inside a `<template>`. If the host
1494
- * has been moved out of that template, React's unmount won't reach
1495
- * it, so remove it directly. A host still in its template is left
1496
- * for React to remove.
1497
- */
1498
- if (!(node.parentElement instanceof HTMLTemplateElement)) {
1499
- node.remove();
1500
- }
1568
+ if (node.isConnected && !this.props.isNested && this.portalTarget && node.parentNode !== this.portalTarget) {
1569
+ this.relocatedPortalHost = { node, parent: node.parentNode, nextSibling: node.nextSibling };
1570
+ this.portalTarget.appendChild(node);
1571
+ }
1572
+ /**
1573
+ * React also calls this when it only *hides* a subtree - a Suspense
1574
+ * fallback, an Offscreen tree, the StrictMode cycle - and remounts the
1575
+ * same instance on the reveal. Nothing here tells the two apart, so the
1576
+ * teardown waits a microtask and runs only if the marker really left.
1577
+ */
1578
+ const marker = this.markerRef.current;
1579
+ const mountCount = this.mountCount;
1580
+ queueMicrotask(() => {
1581
+ // Already back (the StrictMode cycle remounts before this runs).
1582
+ if (this.mountCount !== mountCount) {
1583
+ return;
1501
1584
  }
1502
- else if (this.portalTarget && node.parentNode !== this.portalTarget) {
1503
- /**
1504
- * Portaled overlays: move the host back into `portalTarget` so
1505
- * React's portal `removeChild` can find it.
1506
- */
1507
- this.portalTarget.appendChild(node);
1585
+ if (marker?.isConnected) {
1586
+ this.watchForDestroyWhileHidden(marker, node, mountCount);
1587
+ return;
1588
+ }
1589
+ this.cleanupAfterUnmount(node);
1590
+ });
1591
+ }
1592
+ // Undoes the move `componentWillUnmount` made. Skipped if something else
1593
+ // moved the host while hidden, since the pre-hide position is then wrong.
1594
+ restoreRelocatedPortalHost() {
1595
+ const relocated = this.relocatedPortalHost;
1596
+ this.relocatedPortalHost = undefined;
1597
+ if (!relocated || !relocated.parent.isConnected || relocated.node.parentNode !== this.portalTarget) {
1598
+ return;
1599
+ }
1600
+ const { node, parent, nextSibling } = relocated;
1601
+ // The recorded sibling can have moved or been removed while hidden, in
1602
+ // which case appending is the closest we can get.
1603
+ parent.insertBefore(node, nextSibling?.parentNode === parent ? nextSibling : null);
1604
+ }
1605
+ // `cleanupAfterUnmount` decides what to undo. Gating on the state at hide
1606
+ // time would miss an overlay that only starts presenting afterwards.
1607
+ watchForDestroyWhileHidden(marker, node, mountCount) {
1608
+ this.hiddenDestroyWatch = watchForDestroy(marker, () => {
1609
+ this.hiddenDestroyWatch = undefined;
1610
+ // A reveal already happened, so `componentDidMount` owns the teardown.
1611
+ if (this.mountCount !== mountCount) {
1612
+ return;
1508
1613
  }
1614
+ this.cleanupAfterUnmount(node);
1615
+ });
1616
+ }
1617
+ stopWatchingForDestroy() {
1618
+ unwatchForDestroy(this.hiddenDestroyWatch);
1619
+ this.hiddenDestroyWatch = undefined;
1620
+ }
1621
+ cleanupAfterUnmount(node) {
1622
+ this.nodesAtUnmount = null;
1623
+ this.relocatedPortalHost = undefined;
1624
+ /**
1625
+ * Nested overlays render inline inside a `<template>`. React's unmount
1626
+ * won't reach a host that has been moved out of one, so remove it here.
1627
+ * A host still in its template is left for React.
1628
+ */
1629
+ if (this.props.isNested && node.isConnected && !(node.parentElement instanceof HTMLTemplateElement)) {
1630
+ node.remove();
1509
1631
  }
1510
1632
  /**
1511
1633
  * If the overlay is being unmounted, but is still
@@ -1573,14 +1695,14 @@ const createInlineOverlayComponent = (tagName, defineCustomElement, hasDelegateH
1573
1695
  className: getWrapperClasses(),
1574
1696
  }, children)
1575
1697
  : null));
1576
- // Top-level overlays portal into `portalTarget` with a marker
1577
- // `<template>` at the JSX location to recover the immediate JSX
1578
- // parent after CoreDelegate teleports. Nested overlays and SSR
1579
- // fall back to a `<template>` wrapper.
1698
+ // The marker sits at the JSX location on both branches and leaves the
1699
+ // document only on a real unmount, which is how `componentWillUnmount`
1700
+ // tells a hide from a destroy. Portaled overlays also recover their
1701
+ // JSX parent from it.
1580
1702
  if (!isNested && this.portalTarget) {
1581
1703
  return createElement(React.Fragment, null, createElement('template', { ref: this.markerRef }), createPortal(overlayElement, this.portalTarget));
1582
1704
  }
1583
- return createElement('template', {}, overlayElement);
1705
+ return createElement('template', { ref: this.markerRef }, overlayElement);
1584
1706
  }
1585
1707
  static get displayName() {
1586
1708
  return displayName;
@@ -1606,18 +1728,20 @@ const createInlineOverlayComponent = (tagName, defineCustomElement, hasDelegateH
1606
1728
  this.props.onWillPresent && this.props.onWillPresent(evt);
1607
1729
  };
1608
1730
  handleDidDismiss = (evt) => {
1609
- const wrapper = this.wrapperRef.current;
1610
- const el = this.ref.current;
1731
+ const wrapper = this.wrapperRef.current ?? this.nodesAtUnmount?.wrapper ?? null;
1732
+ const el = this.ref.current ?? this.nodesAtUnmount?.host ?? null;
1611
1733
  /**
1612
- * This component might be unmounted already, if the containing
1613
- * element was removed while the overlay was still open. (For
1614
- * example, if an item contains an inline overlay with a button
1615
- * that removes the item.)
1734
+ * React's `removeChild` needs the wrapper as a direct child of the host,
1735
+ * and a scoped overlay's slot relocation can nest it deeper. So this runs
1736
+ * before the flip below, which triggers that removal. Appending one that
1737
+ * is already a direct child would reorder it against core's own nodes.
1616
1738
  */
1617
- if (wrapper && el) {
1739
+ if (wrapper && el && wrapper.parentElement !== el) {
1618
1740
  el.append(wrapper);
1619
- this.setState({ isOpen: false });
1620
1741
  }
1742
+ // Flip either way, or a dismiss landing while hidden leaves the contents
1743
+ // mounted against a closed overlay on reveal. A no-op after a real unmount.
1744
+ this.setState({ isOpen: false });
1621
1745
  this.props.onDidDismiss && this.props.onDidDismiss(evt);
1622
1746
  };
1623
1747
  };