Skip to content

refactor: remove Vue.js dependency from island custom element#63

Open
ktsn wants to merge 2 commits intomainfrom
worktree-remove-vue-custom-element
Open

refactor: remove Vue.js dependency from island custom element#63
ktsn wants to merge 2 commits intomainfrom
worktree-remove-vue-custom-element

Conversation

@ktsn
Copy link
Owner

@ktsn ktsn commented Feb 27, 2026

Summary

  • Replace Vue's defineCustomElement with a plain HTMLElement subclass in src/client/custom-element.ts
  • Vue now loads on-demand via dynamic import('vue') alongside each island component at hydration time, keeping the entry script Vue-free
  • Uses light DOM directly instead of shadow DOM with <slot> projection

Test plan

  • pnpm build passes
  • pnpm lint passes
  • All 52 tests pass (pnpm test --run)
  • Hydration tests confirm islands still mount and hydrate correctly

Summary by CodeRabbit

  • Refactor
    • Updated the Vue Island custom element implementation to improve lifecycle management and internal architecture while maintaining existing functionality.

Replace Vue's defineCustomElement with a plain HTMLElement subclass so
the entry script no longer bundles Vue. Vue now loads on-demand via
dynamic import alongside each island component at hydration time.
@coderabbitai
Copy link

coderabbitai bot commented Feb 27, 2026

Important

Review skipped

Review was skipped due to path filters

⛔ Files ignored due to path filters (1)
  • test/__snapshots__/hydration.test.ts.snap is excluded by !**/*.snap

CodeRabbit blocks several paths by default. You can override this behavior by explicitly including those paths in the path filters. For example, including **/dist/** will override the default block on the dist directory, by removing the pattern from both the lists.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The custom element implementation transitions from Vue's defineCustomElement with SSR hydration to a native Web Component class that dynamically imports Vue and mounts app instances during the component lifecycle via connectedCallback and disconnectedCallback hooks.

Changes

Cohort / File(s) Summary
Custom Element Refactoring
src/client/custom-element.ts
Replaced Vue SSR-based custom element with a standard Web Component class. Now reads entry and serialized-props attributes, dynamically imports Vue and the entry module in connectedCallback, creates and mounts an SSR app instance, and unmounts during disconnectedCallback. Removes prior hydration and host-based mounting logic.

Sequence Diagram(s)

sequenceDiagram
    participant DOM as Browser/DOM
    participant VueIsland as VueIsland<br/>Web Component
    participant Vue as Vue Library
    participant EntryModule as Entry Module
    participant VueApp as Vue SSR App

    DOM->>VueIsland: connectedCallback()
    VueIsland->>VueIsland: Read entry & serialized-props<br/>attributes
    VueIsland->>Vue: Dynamic import
    VueIsland->>EntryModule: Dynamic import
    VueIsland->>Vue: createSSRApp(component, props)
    Vue->>VueApp: Create app instance
    VueIsland->>VueApp: mount(element)
    VueApp->>VueIsland: Mounted & rendered

    DOM->>VueIsland: disconnectedCallback()
    VueIsland->>VueApp: unmount()
    VueApp->>VueIsland: Cleanup complete
    VueIsland->>VueIsland: Clear `#app` reference
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 A Web Component hops so bright,
No more SSR hydration's might!
connectedCallback lights the way,
Vue mounts fresh with props at play.
With cleanup clean on disconnect's call,
The island stands proud, best of all! 🏝️

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately reflects the main refactoring objective: removing Vue.js dependency from the island custom element by replacing Vue's defineCustomElement with a plain HTMLElement subclass.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch worktree-remove-vue-custom-element

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

Comment @coderabbitai help to get the list of available commands and usage tips.

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 (1)
src/client/custom-element.ts (1)

23-23: Consider wrapping JSON.parse in try-catch for robustness.

If serialized-props contains malformed JSON, this will throw an uncaught exception. While the server should produce valid JSON, adding defensive error handling would improve resilience.

🛡️ Proposed fix
-    const parsedProps = JSON.parse(serializedProps)
+    let parsedProps: Record<string, unknown>
+    try {
+      parsedProps = JSON.parse(serializedProps)
+    } catch {
+      console.error('[vue-island] Failed to parse serialized-props:', serializedProps)
+      return
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client/custom-element.ts` at line 23, Wrap the JSON.parse call that
produces parsedProps from serializedProps in a try-catch to avoid uncaught
exceptions on malformed input: catch any SyntaxError (or generic Error), log or
handle the failure appropriately (e.g., fallback to an empty object or return
early) and ensure parsedProps is set to a safe default; update the logic where
parsedProps is used so downstream code can handle the fallback case. Target the
JSON.parse(serializedProps) expression and the variable parsedProps when making
this change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/client/custom-element.ts`:
- Around line 9-27: connectedCallback is async and can race with
disconnection/reconnection; before and after awaiting imports you must guard
against stale runs and avoid mounting to a disconnected element. Add a
per-instance run token/version (increment on each connectedCallback entry) and
store it (e.g., this._connectToken); if an existing this.#app exists when
starting a new connection, unmount and clear it; after the Promise.all resolve,
check that the current token still matches and that this.isConnected is true
before calling createSSRApp(...).mount(this); if the check fails, do not mount
(and clean up any created app) so stale async work cannot mount into a removed
element.

---

Nitpick comments:
In `@src/client/custom-element.ts`:
- Line 23: Wrap the JSON.parse call that produces parsedProps from
serializedProps in a try-catch to avoid uncaught exceptions on malformed input:
catch any SyntaxError (or generic Error), log or handle the failure
appropriately (e.g., fallback to an empty object or return early) and ensure
parsedProps is set to a safe default; update the logic where parsedProps is used
so downstream code can handle the fallback case. Target the
JSON.parse(serializedProps) expression and the variable parsedProps when making
this change.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e793d72 and 19b8a06.

⛔ Files ignored due to path filters (2)
  • test/__snapshots__/hydration.test.ts.snap is excluded by !**/*.snap
  • test/__snapshots__/prod.test.ts.snap is excluded by !**/*.snap
📒 Files selected for processing (1)
  • src/client/custom-element.ts

Comment on lines +9 to +27
async connectedCallback(): Promise<void> {
const entry = this.getAttribute('entry')
if (!entry) {
return
}

const serializedProps = this.getAttribute('serialized-props') ?? '{}'

const [{ createSSRApp }, module] = await Promise.all([
import('vue'),
import(/* @vite-ignore */ entry),
])

const entryComponent = module.default
const parsedProps = JSON.parse(serializedProps)

this.#app = createSSRApp(entryComponent, parsedProps)
this.#app.mount(this)
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Race condition: element may be disconnected before mount completes.

Since connectedCallback is async, the element could be removed from DOM while awaiting the imports. Mounting to a disconnected element can cause hydration issues. Additionally, if the element is rapidly reconnected, a second connectedCallback can start before the first completes, orphaning the first app instance.

🛡️ Proposed fix to guard against race conditions
   async connectedCallback(): Promise<void> {
     const entry = this.getAttribute('entry')
     if (!entry) {
       return
     }

     const serializedProps = this.getAttribute('serialized-props') ?? '{}'

     const [{ createSSRApp }, module] = await Promise.all([
       import('vue'),
       import(/* `@vite-ignore` */ entry),
     ])

+    // Guard against disconnection during async import or concurrent connectedCallback calls
+    if (!this.isConnected || this.#app) {
+      return
+    }
+
     const entryComponent = module.default
     const parsedProps = JSON.parse(serializedProps)

     this.#app = createSSRApp(entryComponent, parsedProps)
     this.#app.mount(this)
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async connectedCallback(): Promise<void> {
const entry = this.getAttribute('entry')
if (!entry) {
return
}
const serializedProps = this.getAttribute('serialized-props') ?? '{}'
const [{ createSSRApp }, module] = await Promise.all([
import('vue'),
import(/* @vite-ignore */ entry),
])
const entryComponent = module.default
const parsedProps = JSON.parse(serializedProps)
this.#app = createSSRApp(entryComponent, parsedProps)
this.#app.mount(this)
}
async connectedCallback(): Promise<void> {
const entry = this.getAttribute('entry')
if (!entry) {
return
}
const serializedProps = this.getAttribute('serialized-props') ?? '{}'
const [{ createSSRApp }, module] = await Promise.all([
import('vue'),
import(/* `@vite-ignore` */ entry),
])
// Guard against disconnection during async import or concurrent connectedCallback calls
if (!this.isConnected || this.#app) {
return
}
const entryComponent = module.default
const parsedProps = JSON.parse(serializedProps)
this.#app = createSSRApp(entryComponent, parsedProps)
this.#app.mount(this)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/client/custom-element.ts` around lines 9 - 27, connectedCallback is async
and can race with disconnection/reconnection; before and after awaiting imports
you must guard against stale runs and avoid mounting to a disconnected element.
Add a per-instance run token/version (increment on each connectedCallback entry)
and store it (e.g., this._connectToken); if an existing this.#app exists when
starting a new connection, unmount and clear it; after the Promise.all resolve,
check that the current token still matches and that this.isConnected is true
before calling createSSRApp(...).mount(this); if the check fails, do not mount
(and clean up any created app) so stale async work cannot mount into a removed
element.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant