Преглед изворни кода

fix(custom-element): keep nested fallback blocks live in shadowRoot false custom elements

daiwei пре 2 дана
родитељ
комит
324aacc2b7

+ 20 - 4
packages/runtime-dom/src/apiCustomElement.ts

@@ -265,7 +265,12 @@ export abstract class VueElementBase<
   protected abstract _mount(def: Def): void
   protected abstract _update(): void
   protected abstract _unmount(): void
-  protected abstract _updateSlotNodes(slot: Map<Node, Node[]>): void
+  // `usedFallback` preserves whether the outlet rendered native slotted
+  // content or its own fallback DOM so implementations can keep the right
+  // ownership model when syncing their block trees.
+  protected abstract _updateSlotNodes(
+    slot: Map<Node, { nodes: Node[]; usedFallback: boolean }>,
+  ): void
 
   constructor(
     /**
@@ -693,7 +698,13 @@ export abstract class VueElementBase<
   protected _renderSlots(): void {
     const outlets = this._getSlots()
     const scopeId = this._instance!.type.__scopeId
-    const slotReplacements: Map<Node, Node[]> = new Map()
+    // Record both the final DOM nodes and whether they came from fallback.
+    // The nodes alone are not enough for runtimes that need to distinguish a
+    // plain DOM replacement from a live fallback owner.
+    const slotReplacements: Map<
+      Node,
+      { nodes: Node[]; usedFallback: boolean }
+    > = new Map()
 
     for (let i = 0; i < outlets.length; i++) {
       const o = outlets[i] as HTMLSlotElement
@@ -725,7 +736,10 @@ export abstract class VueElementBase<
         }
       }
       parent.removeChild(o)
-      slotReplacements.set(o, replacementNodes)
+      slotReplacements.set(o, {
+        nodes: replacementNodes,
+        usedFallback: !content,
+      })
     }
 
     this._updateSlotNodes(slotReplacements)
@@ -866,7 +880,9 @@ export class VueElement extends VueElementBase<
   /**
    * Only called when shadowRoot is false
    */
-  protected _updateSlotNodes(replacements: Map<Node, Node[]>): void {
+  protected _updateSlotNodes(
+    replacements: Map<Node, { nodes: Node[]; usedFallback: boolean }>,
+  ): void {
     // do nothing
   }
 

+ 46 - 0
packages/runtime-vapor/__tests__/customElement.spec.ts

@@ -1721,6 +1721,52 @@ describe('defineVaporCustomElement', () => {
       )
     })
 
+    test('should unmount nested slot fallback rendered from outer fallback after updates', async () => {
+      const showNamedFallback = ref(true)
+      const NestedFallback = defineVaporCustomElement(
+        {
+          setup() {
+            return createSlot('default', null, () =>
+              createSlot('named', null, () =>
+                createIf(
+                  () => showNamedFallback.value,
+                  () => template('<span>named fallback</span>')(),
+                ),
+              ),
+            )
+          },
+        },
+        { shadowRoot: false },
+      )
+      customElements.define(
+        'my-el-shadowroot-false-nested-fallback-unmount',
+        NestedFallback,
+      )
+
+      container.innerHTML =
+        `<my-el-shadowroot-false-nested-fallback-unmount>` +
+        `</my-el-shadowroot-false-nested-fallback-unmount>`
+      const e = container.childNodes[0] as VaporElement
+
+      expect(e.innerHTML).toBe(
+        `<span>named fallback</span><!--if--><!--slot--><!--slot-->`,
+      )
+
+      showNamedFallback.value = false
+      await nextTick()
+      expect(e.innerHTML).toBe(`<!--if--><!--slot--><!--slot-->`)
+      showNamedFallback.value = true
+      await nextTick()
+      expect(e.innerHTML).toBe(
+        `<span>named fallback</span><!--if--><!--slot--><!--slot-->`,
+      )
+
+      container.removeChild(e)
+      await nextTick()
+      expect(e.innerHTML).toBe(``)
+      expect(e._instance).toBe(null)
+    })
+
     test('render nested customElement w/ shadowRoot false', async () => {
       const calls: string[] = []
 

+ 23 - 32
packages/runtime-vapor/src/apiDefineCustomElement.ts

@@ -29,7 +29,7 @@ import type {
   VaporRenderResult,
 } from './apiDefineComponent'
 import type { StaticSlots } from './componentSlots'
-import { isFragment } from './fragment'
+import { SlotFragment, isFragment } from './fragment'
 
 export type VaporElementConstructor<P = {}> = {
   new (initialProps?: Record<string, any>): VaporElement & P
@@ -285,7 +285,9 @@ export class VaporElement extends VueElementBase<
   /**
    * Only called when shadowRoot is false
    */
-  protected _updateSlotNodes(replacements: Map<Node, Node[]>): void {
+  protected _updateSlotNodes(
+    replacements: Map<Node, { nodes: Node[]; usedFallback: boolean }>,
+  ): void {
     this._updateFragmentNodes(
       (this._instance! as VaporComponentInstance).block,
       replacements,
@@ -298,23 +300,8 @@ export class VaporElement extends VueElementBase<
    */
   private _updateFragmentNodes(
     block: Block,
-    replacements: Map<Node, Node[]>,
+    replacements: Map<Node, { nodes: Node[]; usedFallback: boolean }>,
   ): void {
-    const appendReplacementNodes = (
-      slot: HTMLSlotElement,
-      target: Block[],
-    ): void => {
-      const replacement = replacements.get(slot)
-      if (!replacement) return
-      for (const node of replacement) {
-        if (node instanceof HTMLSlotElement) {
-          appendReplacementNodes(node, target)
-        } else {
-          target.push(node)
-        }
-      }
-    }
-
     if (Array.isArray(block)) {
       block.forEach(item => this._updateFragmentNodes(item, replacements))
       return
@@ -322,21 +309,25 @@ export class VaporElement extends VueElementBase<
 
     if (!isFragment(block)) return
     const { nodes } = block
-    if (Array.isArray(nodes)) {
-      const newNodes: Block[] = []
-      for (const node of nodes) {
-        if (node instanceof HTMLSlotElement) {
-          appendReplacementNodes(node, newNodes)
-        } else {
-          this._updateFragmentNodes(node, replacements)
-          newNodes.push(node)
-        }
+    if (nodes instanceof HTMLSlotElement) {
+      const replacement = replacements.get(nodes)
+      if (!replacement) return
+
+      // Slotted content can be represented as plain nodes, but fallback must
+      // stay as its live block so nested updates and unmounting keep using the
+      // current owner rather than a stale DOM snapshot.
+      if (
+        replacement.usedFallback &&
+        block instanceof SlotFragment &&
+        block.customElementFallback
+      ) {
+        this._updateFragmentNodes(block.customElementFallback, replacements)
+        block.nodes = block.customElementFallback
+      } else {
+        block.nodes = replacement.nodes
       }
-      block.nodes = newNodes
-    } else if (nodes instanceof HTMLSlotElement) {
-      const newNodes: Block[] = []
-      appendReplacementNodes(nodes, newNodes)
-      block.nodes = newNodes
+    } else if (Array.isArray(nodes)) {
+      nodes.forEach(item => this._updateFragmentNodes(item, replacements))
     } else {
       this._updateFragmentNodes(nodes, replacements)
     }

+ 6 - 1
packages/runtime-vapor/src/componentSlots.ts

@@ -244,7 +244,12 @@ export function createSlot(
         })
         if (fallback) {
           withOwnedSlotBoundary(slotFragment.parentSlotBoundary, () => {
-            insert(fallback(), el)
+            const fallbackBlock = fallback()
+            // Keep the live fallback owner on the SlotFragment itself. The
+            // native slot outlet is temporary and gets removed by CE slot
+            // replacement, but the fragment remains Vapor's long-lived owner.
+            slotFragment.customElementFallback = fallbackBlock
+            insert(fallbackBlock, el)
           })
         }
         fragment.nodes = el

+ 4 - 0
packages/runtime-vapor/src/fragment.ts

@@ -1133,6 +1133,10 @@ function isReusableDynamicFragmentAnchor(
 export class SlotFragment extends DynamicFragment {
   forwarded = false
   parentSlotBoundary: SlotBoundaryContext | null = getCurrentSlotBoundary()
+  // Custom elements with `shadowRoot: false` replace their native slot outlet
+  // after mount. Keep the live fallback owner on the fragment so CE slot sync
+  // can preserve block ownership after the outlet node is gone.
+  customElementFallback?: Block
   private localFallback?: BlockFn
   private isUpdatingSlot = false
   private readonly controller: SlotFallbackController