Skip to content

fix(custom-element): properly handle custom element remove then insert again #12413

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from

Conversation

lejunyang
Copy link

@lejunyang lejunyang commented Nov 16, 2024

close #12412

changes:
- extract observer code
- observe again and re-mount if it's resolved and no instance, new parent will be set on new mount
- exposed properties should be configurable, and we should delete them if element is fully unmounted, so that we can expose again in new mount

changes:
- extract unmount code, delete exposed properties when unmounting, clear parent and set resolved to false
- unmount if parent changed when connected
- delete invalid exposed key from exposed when mounting

changes:

  • extract observer code
  • extract unmount code, delete exposed properties when unmounting, clear parent
  • unmount if parent changed in connectedCallback and then re-mount
  • observe again and re-mount if it's resolved and no instance, new parent will be set on new mount
  • delete invalid exposed key from exposed when mounting

Summary by CodeRabbit

  • Bug Fixes
    • Improved handling of custom elements when moved or removed from the DOM, ensuring correct updates and style management without errors.
  • Tests
    • Added new tests to verify custom element behavior during DOM removal, re-attachment, and parent changes, ensuring proper context and style updates.
  • Refactor
    • Streamlined internal logic for mounting, unmounting, and observing custom elements to enhance reliability and maintainability.

…ove element and append it back after fully unmounted (vuejs#12412)
Copy link

github-actions bot commented Nov 18, 2024

Size Report

Bundles

File Size Gzip Brotli
runtime-dom.global.prod.js 100 kB (+222 B) 38 kB (+79 B) 34.3 kB (+90 B)
vue.global.prod.js 158 kB (+222 B) 57.9 kB (+76 B) 51.5 kB (+82 B)

Usages

Name Size Gzip Brotli
createApp (CAPI only) 47.2 kB 18.3 kB 16.8 kB
createApp 55.2 kB 21.3 kB 19.5 kB
createSSRApp 59.3 kB 23.1 kB 21 kB
defineCustomElement 60.3 kB (+222 B) 23 kB (+67 B) 20.9 kB (+67 B)
overall 69.1 kB 26.5 kB 24.1 kB

Copy link

pkg-pr-new bot commented Nov 18, 2024

Open in Stackblitz

@vue/compiler-core

pnpm add https://pkg.pr.new/@vue/compiler-core@12413

@vue/compiler-dom

pnpm add https://pkg.pr.new/@vue/compiler-dom@12413

@vue/compiler-ssr

pnpm add https://pkg.pr.new/@vue/compiler-ssr@12413

@vue/compiler-sfc

pnpm add https://pkg.pr.new/@vue/compiler-sfc@12413

@vue/reactivity

pnpm add https://pkg.pr.new/@vue/reactivity@12413

@vue/runtime-core

pnpm add https://pkg.pr.new/@vue/runtime-core@12413

@vue/runtime-dom

pnpm add https://pkg.pr.new/@vue/runtime-dom@12413

@vue/server-renderer

pnpm add https://pkg.pr.new/@vue/server-renderer@12413

@vue/shared

pnpm add https://pkg.pr.new/@vue/shared@12413

vue

pnpm add https://pkg.pr.new/vue@12413

@vue/compat

pnpm add https://pkg.pr.new/@vue/compat@12413

commit: 53077a5

} else if (__DEV__) {
warn(`Exposed property "${key}" already exists on custom element.`)
} else {
delete exposed[key] // delete it from exposed in case of deleting wrong exposed key when disconnected
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you provide more info on why need this line?

Copy link
Author

@lejunyang lejunyang Nov 18, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is to remove invalid exposed key.
Let's say we have an element with prop foo, and we accidentally expose an object {foo: 1}, it will be ignored as foo is already on this element. In disconnectedCallback, we'll delete exposed properties according to exposed, but deleting foo can cause an error as it's non-configurable prop.
Therefore, we should remove that key ahead

@edison1105
Copy link
Member

Thank you for your contribution. I have some suggestions and questions.

Click "remove el2" and "append el2"

I think the reason for the error here is that _parent and _resolved were not reset when disconnected. Therefore, the correct fix should be:

// in disconnectedCallback
this._parent = undefined
this._resolved = false

and then click "change el2 attr", its attribute is changed, but prop is not updated

The fix to this part are generally correct. However, I don't understand why this line needs to be added.

extract observer code

I think this change is no longer necessary if reset _parent and _resolved in disconnectedCallback

@lejunyang
Copy link
Author

lejunyang commented Nov 18, 2024

@edison1105 Thanks for your suggestions! It's a good fix for current issues.
However I found another issue regarding moving element. If we move a child element directly into other element(not waiting two nextTick), its parent context is not updated

I will check more on this and update issue and commit this night

@lejunyang
Copy link
Author

lejunyang commented Nov 18, 2024

Actually setting _resolved to false can cause some duplicate executions, like _applyStyles

@edison1105
Copy link
Member

Actually setting _resolved to false can cause some duplicate executions, like _applyStyles

re-mount should also re-apply the style, right ?

@lejunyang lejunyang marked this pull request as draft November 18, 2024 15:18
@lejunyang lejunyang marked this pull request as ready for review November 18, 2024 15:40
@lejunyang lejunyang changed the title fix(custom-element): fix parent, observer and exposed issues when remove element and append it back after fully unmounted fix(custom-element): fix parent, observer and exposed issues when remove element and append it back immediately or after fully unmounted Nov 18, 2024
@lejunyang
Copy link
Author

Oh, I forgot to run e2e tests in my local, my bad. I can see it's from _parseSlots, child custom element is removed and unmounted... let me see how to handle this

@lejunyang
Copy link
Author

lejunyang commented Nov 19, 2024

The reason work with Teleport (shadowRoot: false) test case fails is that: my-y is defined first, my-p is defined later, and it removes my-y and causes unmounting.
I think this is not code issue, it's test case issue. We should always define outer element first, especially when SSR, otherwise inner element can't find correct parent, so I updated this test case

@edison1105 edison1105 added the 🔨 p3-minor-bug Priority 3: this fixes a bug, but is an edge case that only affects very specific usage. label Nov 22, 2024
@edison1105
Copy link
Member

While fixing #12453, I realized I made a mistake and my previous review was incorrect. Your initial submission might have been correct. this._resolved = false will lead to the following code never running. I apologize for this.

if (this._resolved) {
  this._setParent()
  this._update()
}

We should reuse the already resolved _def.

@edison1105 edison1105 changed the title fix(custom-element): fix parent, observer and exposed issues when remove element and append it back immediately or after fully unmounted fix(custom-element): properly handle custom element remove then insert again Nov 22, 2024
@lejunyang
Copy link
Author

Updated, thanks for the info
Also I was correct, this._resolved = false will lead to applyStyle twice. unmount doesn't remove style nodes so we don't need to applyStyle again. I have added that in test case
So we just need to mount again instead of resolveDef again

@wolandec
Copy link

Hey team any updates on this one?

@HaNdTriX
Copy link

HaNdTriX commented May 9, 2025

I just tested this Branch:

 pnpm add https://pkg.pr.new/vue@12413

The fix solves this issue. Thanks @lejunyang

@edison1105 edison1105 added the ready to merge The PR is ready to be merged. label May 9, 2025
Copy link

coderabbitai bot commented May 9, 2025

Walkthrough

This update refactors the custom element lifecycle in Vue's runtime-dom package, modularizing unmounting and mutation observer logic. It adds comprehensive tests for removing, re-appending, and moving custom elements, verifying correct context, property, and style handling. The changes address issues with attribute updates, exposed properties, and parent-child relationships after DOM manipulations.

Changes

File(s) Change Summary
packages/runtime-dom/src/apiCustomElement.ts Refactored lifecycle methods: introduced _unmount and _observe for modular unmounting and mutation observer management; improved handling of parent changes, attribute observation, and exposed property lifecycle; simplified disconnectedCallback.
packages/runtime-dom/__tests__/customElement.spec.ts Added two new tests for custom elements: one for removing and re-appending elements, and another for moving elements between parents. Tests verify correct context, exposed methods, attribute updates, and style management after DOM changes.
packages/vue/__tests__/e2e/ssr-custom-element.spec.ts Changed order of custom element registrations in a test; no logic or behavioral impact.

Sequence Diagram(s)

sequenceDiagram
    participant Browser
    participant CustomElement (VueElement)
    participant VueAppInstance

    Browser->>CustomElement: connectedCallback()
    CustomElement->>CustomElement: Check parent change
    alt Parent changed and previously mounted
        CustomElement->>CustomElement: _unmount(keepParentAndOb = true)
    end
    CustomElement->>CustomElement: _mount()
    CustomElement->>VueAppInstance: Initialize app

    Browser->>CustomElement: disconnectedCallback()
    CustomElement->>CustomElement: _unmount()
    CustomElement->>VueAppInstance: Unmount app
Loading

Assessment against linked issues

Objective Addressed Explanation
Removing and re-appending a Vue custom element works without errors, including nested custom elements (#12412)
Attribute changes after re-append update props and context as expected (#12412)
Exposed properties are cleared and re-initialized correctly after unmount/remount (#12412)
Mutation observer is correctly managed and attribute reflection works after DOM manipulation (#12412)

Poem

A hop, a leap, a custom feat—
Now Vue's elements can't be beat!
Removed or moved, they find their way,
With props and styles that always stay.
No errors now, just smooth delight,
As rabbits cheer this bug's good night!
🐇✨

Tip

⚡️ Faster reviews with caching
  • CodeRabbit now supports caching for code and dependencies, helping speed up reviews. This means quicker feedback, reduced wait times, and a smoother review experience overall. Cached data is encrypted and stored securely. This feature will be automatically enabled for all accounts on May 16th. To opt out, configure Review - Disable Cache at either the organization or repository level. If you prefer to disable all data retention across your organization, simply turn off the Data Retention setting under your Organization Settings.

Enjoy the performance boost—your workflow just got faster.

✨ Finishing Touches
  • 📝 Generate Docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Generate unit testing code for this file.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query. Examples:
    • @coderabbitai generate unit testing code for this file.
    • @coderabbitai modularize this function.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read src/utils.ts and generate unit testing code.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.
    • @coderabbitai help me debug CodeRabbit configuration file.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

CodeRabbit Commands (Invoked using PR comments)

  • @coderabbitai pause to pause the reviews on a PR.
  • @coderabbitai resume to resume the paused reviews.
  • @coderabbitai review to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
  • @coderabbitai full review to do a full review from scratch and review all the files again.
  • @coderabbitai summary to regenerate the summary of the PR.
  • @coderabbitai generate docstrings to generate docstrings for this PR.
  • @coderabbitai generate sequence diagram to generate a sequence diagram of the changes in this PR.
  • @coderabbitai resolve resolve all the CodeRabbit review comments.
  • @coderabbitai configuration to show the current CodeRabbit configuration for the repository.
  • @coderabbitai help to get help.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Documentation and Community

  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (5)
packages/runtime-dom/__tests__/customElement.spec.ts (2)

167-176: Minor typing nit – return-type annotation

text: () => string technically warns in strict TS because string here is a type, not a value.
It still compiles due to contextual typing, but writing text: () => string | undefined (or omitting the annotation altogether) avoids the redundant type literal while keeping full type-safety.

-        text: () => props.msg,
+        text: () => props.msg,

The implementation is fine; this is just a cosmetic typing tip.


190-203: Good move test – one follow-up idea

The test currently covers parent context changes.
Consider also asserting that the old parent’s context is no longer injected into the moved child (i.e. it doesn’t update when the old parent’s msg changes). This guards against stale provide/inject references.

packages/runtime-dom/src/apiCustomElement.ts (3)

289-304: Parent-change re-mount logic is correct but can double-observe

After _unmount(true) the former MutationObserver is still active.
Because _instance is now null, the subsequent if (!this._instance) this._observe() re-invokes observe() on an already observing instance.
While the spec silently replaces the previous options, it still costs an extra “disconnect+observe” cycle and can trigger redundant mutation records in certain polyfills.

-        // no instance means no observer
-        if (!this._instance) this._observe()
+        // restart observer only if it was disconnected by _unmount
+        if (!this._instance && !this._ob) this._observe()

358-368: Observer helper extracted – small optimisation

Nice extraction!
Two quick wins:

  1. You can pass attributeFilter to avoid receiving style/class changes that Vue never reflects.
  2. In _setProp you reconnect the observer for every reflected write. You can avoid the disconnect/observe round-trip when shouldReflect is false.

Not critical, but it avoids needless micro-tasks and reduces the mutation backlog in attribute-heavy components.

Also applies to: 544-545


456-465: Configurable expose getters – 👍 with a caution

Making the descriptors configurable fixes the deletion error.
However, the else-branch silently drops conflicting exposed keys. That can hide user mistakes at run-time.

Consider throwing (or at least warning in prod) so integrators discover the conflict early:

-        delete exposed[key]
-        if (__DEV__)
-          warn(`Exposed property "${key}" already exists on custom element.`)
+        delete exposed[key]
+        warn(
+          `Exposed property "${key}" conflicts with an existing property and ` +
+            `was ignored.  Rename the exposed property to avoid collisions.`,
+        )
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5a5406d and 6b52371.

📒 Files selected for processing (3)
  • packages/runtime-dom/__tests__/customElement.spec.ts (1 hunks)
  • packages/runtime-dom/src/apiCustomElement.ts (5 hunks)
  • packages/vue/__tests__/e2e/ssr-custom-element.spec.ts (1 hunks)
🧰 Additional context used
🪛 Biome (1.9.4)
packages/runtime-dom/src/apiCustomElement.ts

[error] 336-336: Change to an optional chain.

Unsafe fix: Change to an optional chain.

(lint/complexity/useOptionalChain)

🔇 Additional comments (2)
packages/vue/__tests__/e2e/ssr-custom-element.spec.ts (1)

112-112: Order-switch looks good

Re-registering the parent (my-p) before the child (my-y) removes the race condition that caused the earlier hydration error – nice catch.

packages/runtime-dom/__tests__/customElement.spec.ts (1)

150-188: Great coverage – verifies style & context after a full disconnect

These assertions catch three common regressions:

  1. exposed methods are cleared,
  2. style sheets aren’t duplicated, and
  3. re-insertion updates reactive context.

Nice work adding the double nextTick() to ensure the element is fully disconnected before re-mounting.

Comment on lines +328 to +347
private _unmount(keepParentAndOb?: boolean) {
if (!keepParentAndOb) {
this._parent = undefined
if (this._ob) {
this._ob.disconnect()
this._ob = null
}
}
this._app && this._app.unmount()
if (this._instance) {
const exposed = this._instance.exposed
if (exposed) {
for (const key in exposed) {
delete this[key as keyof this]
}
}
this._instance.ce = undefined
}
this._app = this._instance = null
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

_unmount(): ensure internal state cleared before deleting exposed keys

delete this[key] is executed before _instance is nulled.
If any exposed getter references this._instance, the getter may run during deletion and access a stale instance.

Moving the this._instance = null assignment above the exposed-key loop eliminates this edge case:

-    if (this._instance) {
+    const inst = this._instance
+    this._instance = null          // prevent stale access in getters
+    if (inst) {
-      const exposed = this._instance.exposed
+      const exposed = inst.exposed

Committable suggestion skipped: line range outside the PR's diff.

🧰 Tools
🪛 Biome (1.9.4)

[error] 336-336: Change to an optional chain.

Unsafe fix: Change to an optional chain.

(lint/complexity/useOptionalChain)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
🔨 p3-minor-bug Priority 3: this fixes a bug, but is an edge case that only affects very specific usage. ready to merge The PR is ready to be merged. scope: custom elements
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Remove vue custom element and append it back later can cause a lot of issues
4 participants