Repository: tipsy/javalin
Pull request #1222 (2021-04-10 14:54:57)

Javalin 3x Patch in Vue3 Changes


Patch:
diff --git a/javalin/src/main/java/io/javalin/plugin/rendering/vue/JavalinVue.kt b/javalin/src/main/java/io/javalin/plugin/rendering/vue/JavalinVue.kt
index f839b8f8..54286b49 100644
--- a/javalin/src/main/java/io/javalin/plugin/rendering/vue/JavalinVue.kt
+++ b/javalin/src/main/java/io/javalin/plugin/rendering/vue/JavalinVue.kt
@@ -6,150 +6,54 @@
 
 package io.javalin.plugin.rendering.vue
 
-import io.javalin.core.util.Header
 import io.javalin.http.Context
-import io.javalin.http.Handler
 import io.javalin.http.staticfiles.Location
 import io.javalin.http.util.ContextUtil.isLocalhost
-import io.javalin.plugin.json.JavalinJson
-import io.javalin.plugin.rendering.vue.FileInliner.inlineFiles
-import io.javalin.plugin.rendering.vue.JavalinVue.getAllDependencies
-import io.javalin.plugin.rendering.vue.JavalinVue.resourcesJarClass
-import java.net.URLEncoder
 import java.nio.file.FileSystems
 import java.nio.file.Files
 import java.nio.file.Path
 import java.nio.file.Paths
-import java.util.regex.Matcher
 import java.util.stream.Collectors
+import io.javalin.plugin.rendering.vue.JavalinVue.resourcesJarClass as jarClass
 
-object JavalinVue {
-
-    internal var isDev: Boolean? = null
-    internal var vueDirPath: Path? = null
+enum class VueVersion { VUE_2, VUE_3 }
 
-    @JvmStatic
-    fun rootDirectory(path: String, location: Location) {
-        vueDirPath = if (location == Location.CLASSPATH) PathMaster.classpathPath(path) else Paths.get(path)
+object JavalinVue {
+    // @formatter:off
+    internal var isDev: Boolean? = null // cached and easily accessible, is set on first request (can't be configured directly by end user)
+    @JvmField var isDevFunction: (Context) -> Boolean = { it.isLocalhost() } // used to set isDev, will be called once
+    @JvmField var optimizeDependencies = true // only include required components for the route component
+    @JvmField var resourcesJarClass: Class<*> = PathMaster::class.java // can be any class in the jar to look for resources in
+    @JvmField var stateFunction: (Context) -> Any = { mapOf<String, String>() } // global state that is injected into all VueComponents
+    @JvmField var cacheControl = "no-cache, no-store, must-revalidate"
+    @JvmField var rootDirectory: Path? = null // is set on first request (if not configured)
+    @JvmStatic fun rootDirectory(path: String, location: Location) {
+        rootDirectory = if (location == Location.CLASSPATH) PathMaster.classpathPath(path) else Paths.get(path)
     }
-
-    @JvmStatic
-    fun rootDirectory(path: Path) {
-        vueDirPath = path
+    @JvmField var vueVersion = VueVersion.VUE_2
+    internal var vueAppName = "Vue" // only relevant for Vue 3 apps
+    @JvmStatic fun vueAppName(appName: String) {
+        vueAppName = if (vueVersion == VueVersion.VUE_3) appName else throw IllegalStateException("Only supported when 'vueVersion' is 'VUE_3'")
     }
-
-    @JvmField
-    var optimizeDependencies = false
-
-    @JvmField
-    var stateFunction: (Context) -> Any = { mapOf<String, String>() }
-
-    @JvmField
-    var cacheControl = "no-cache, no-store, must-revalidate"
-
-    @JvmField
-    var isDevFunction: (Context) -> Boolean = { it.isLocalhost() }
-
-    @JvmField
-    var resourcesJarClass = PathMaster::class
-
-    internal fun walkPaths(): Set<Path> = Files.walk(vueDirPath, 10).collect(Collectors.toSet())
-
+    // @formatter:on
+    fun walkPaths(): Set<Path> = Files.walk(rootDirectory, 20).collect(Collectors.toSet())
     internal val cachedPaths by lazy { walkPaths() }
-    internal val cachedDependencyResolver by lazy { VueDependencyResolver(cachedPaths) }
-
-    internal fun createLayout(paths: Set<Path>, componentDependencies: String): String {
-        return paths.find { it.endsWith("vue/layout.html") }!!.readText()
-                .inlineFiles(paths)
-                .replace("@componentRegistration", "@componentRegistration@serverState") // add state anchor for later
-                .replace("@componentRegistration", componentDependencies)
-                .replaceWebjarsWithCdn()
-    }
-
-    internal fun getAllDependencies(paths: Set<Path>) = paths.filter { it.isVueFile() }
-            .joinToString("") { "\n<!-- ${it.fileName} -->\n" + it.readText() }
-
-    internal fun getState(ctx: Context, state: Any?) = "\n<script>\n" +
-        "Vue.prototype.\$javalin = JSON.parse(decodeURIComponent(\"${
-            JavalinJson.toJson(
-                mapOf(
-                    "pathParams" to ctx.pathParamMap(),
-                    "queryParams" to ctx.queryParamMap(),
-                    "state" to (state ?: stateFunction(ctx))
-                )
-            ).uriEncodeForJavascript()}\"))\n</script>\n"
-
-    // Unfortunately, Java's URLEncoder does not encode the space character in the same way as Javascript.
-    // Javascript expects a space character to be encoded as "%20", whereas Java encodes it as "+".
-    // All other encodings are implemented correctly, therefore we can simply replace the character in the encoded String.
-    private fun String.uriEncodeForJavascript() =
-        URLEncoder.encode(this, Charsets.UTF_8.name()).replace("+","%20")
-
-    private fun String.replaceWebjarsWithCdn() =
-            this.replace("@cdnWebjar/", if (isDev == true) "/webjars/" else "https://cdn.jsdelivr.net/webjars/org.webjars.npm/")
-
-}
-
-
-class VueComponent @JvmOverloads constructor(private val component: String, private val state: Any? = null) : Handler {
-    override fun handle(ctx: Context) {
-        JavalinVue.isDev = JavalinVue.isDev ?: JavalinVue.isDevFunction(ctx)
-        JavalinVue.vueDirPath = JavalinVue.vueDirPath ?: PathMaster.defaultLocation(JavalinVue.isDev)
-        val routeComponent = if (component.startsWith("<")) component else "<$component></$component>"
-        val paths = if (JavalinVue.isDev == true) JavalinVue.walkPaths() else JavalinVue.cachedPaths
-        val componentId = routeComponent.removePrefix("<").takeWhile { it !in setOf('>', ' ') }
-        val dependencyResolver by lazy { if (JavalinVue.isDev == true) VueDependencyResolver(paths) else JavalinVue.cachedDependencyResolver }
-        val view = JavalinVue.createLayout(paths, if (JavalinVue.optimizeDependencies) dependencyResolver.resolve(componentId) else getAllDependencies(paths))
-        if (!view.contains(componentId)) {
-            ctx.result("Route component not found: $routeComponent")
-            return
-        }
-        ctx.header(Header.CACHE_CONTROL, JavalinVue.cacheControl)
-        ctx.html(view.replace("@serverState", JavalinVue.getState(ctx, state)).replace("@routeComponent", routeComponent)) // insert current route component
-    }
+    internal val cachedDependencyResolver by lazy { VueDependencyResolver(cachedPaths, vueAppName) }
 }
 
+/**
+ * By default, [jarClass] is PathMaster::class, which means this code will only
+ * work if the resources are in the same jar as Javalin (i.e. in a fat-jar/uber-jar).
+ * You can change resourcesJarClass to whatever class suits your needs.
+ */
 object PathMaster {
-    /**
-     * By default, resourcesJarClass is PathMaster::class, which means this code will only
-     * work if the resources are in the same jar as Javalin (i.e. in a fat-jar/uber-jar).
-     * You can change resourcesJarClass to whatever class suits your needs.
-     *
-     * Creating a filesystem is required since we want to "walk" the jar (see [JavalinVue.walkPaths])
-     * to find all the .vue files.
-     */
-    private val fileSystem by lazy { FileSystems.newFileSystem(resourcesJarClass.java.getResource("").toURI(), emptyMap<String, Any>()) }
+    /** We create a filesystem to "walk" the jar ([JavalinVue.walkPaths]) to find all the .vue files. */
+    private val fileSystem by lazy { FileSystems.newFileSystem(jarClass.getResource("").toURI(), emptyMap<String, Any>()) }
 
     fun classpathPath(path: String): Path = when {
-        resourcesJarClass.java.getResource(path).toURI().scheme == "jar" -> fileSystem.getPath(path) // we're inside a jar
-        else -> Paths.get(resourcesJarClass.java.getResource(path).toURI()) // we're not in jar (probably running from IDE)
+        jarClass.getResource(path).toURI().scheme == "jar" -> fileSystem.getPath(path) // we're inside a jar
+        else -> Paths.get(jarClass.getResource(path).toURI()) // we're not in jar (probably running from IDE)
     }
 
     fun defaultLocation(isDev: Boolean?) = if (isDev == true) Paths.get("src/main/resources/vue") else classpathPath("/vue")
 }
-
-object FileInliner {
-    private val newlineRegex = Regex("\\r?\\n")
-    private val unconditionalRegex = Regex("""@inlineFile\(".*"\)""")
-    private val devRegex = Regex("""@inlineFileDev\(".*"\)""")
-    private val notDevRegex = Regex("""@inlineFileNotDev\(".*"\)""")
-
-    fun String.inlineFiles(paths: Set<Path>): String {
-        val pathMap = paths.filterNot { it.isVueFile() } // vue files are inlined in @componentRegistration later
-                .associateBy { """"/vue/${it.toString().replace("\\", "/").substringAfter("/vue/")}"""" } // normalize keys
-        return this.split(newlineRegex).joinToString("\n") { line ->
-            if (!line.contains("@inlineFile")) return@joinToString line // nothing to inline
-            val matchingKey = pathMap.keys.find { line.contains(it) } ?: throw IllegalStateException("Invalid path found: $line")
-            val matchingFileContent by lazy { Matcher.quoteReplacement(pathMap[matchingKey]!!.readText()) }
-            when {
-                devRegex.containsMatchIn(line) -> if (JavalinVue.isDev == true) line.replace(devRegex, matchingFileContent) else ""
-                notDevRegex.containsMatchIn(line) -> if (JavalinVue.isDev == false) line.replace(notDevRegex, matchingFileContent) else ""
-                else -> line.replace(unconditionalRegex, matchingFileContent)
-            }
-        }
-    }
-}
-
-fun Path.readText() = String(Files.readAllBytes(this))
-fun Path.isVueFile() = this.toString().endsWith(".vue")
-
diff --git a/javalin/src/main/java/io/javalin/plugin/rendering/vue/VueComponent.kt b/javalin/src/main/java/io/javalin/plugin/rendering/vue/VueComponent.kt
new file mode 100644
index 00000000..991f5848
--- /dev/null
+++ b/javalin/src/main/java/io/javalin/plugin/rendering/vue/VueComponent.kt
@@ -0,0 +1,84 @@
+package io.javalin.plugin.rendering.vue
+
+import io.javalin.core.util.Header
+import io.javalin.http.Context
+import io.javalin.http.Handler
+import io.javalin.http.InternalServerErrorResponse
+import io.javalin.plugin.json.JavalinJson
+import io.javalin.plugin.rendering.vue.FileInliner.inlineFiles
+import io.javalin.plugin.rendering.vue.JavalinVue.cacheControl
+import io.javalin.plugin.rendering.vue.JavalinVue.cachedDependencyResolver
+import io.javalin.plugin.rendering.vue.JavalinVue.cachedPaths
+import io.javalin.plugin.rendering.vue.JavalinVue.isDev
+import io.javalin.plugin.rendering.vue.JavalinVue.isDevFunction
+import io.javalin.plugin.rendering.vue.JavalinVue.optimizeDependencies
+import io.javalin.plugin.rendering.vue.JavalinVue.rootDirectory
+import io.javalin.plugin.rendering.vue.JavalinVue.stateFunction
+import io.javalin.plugin.rendering.vue.JavalinVue.walkPaths
+import java.net.URLEncoder
+import java.nio.file.Files
+import java.nio.file.Path
+import java.util.regex.Matcher
+
+class VueComponent @JvmOverloads constructor(val component: String, val state: Any? = null) : Handler {
+    override fun handle(ctx: Context) {
+        isDev = isDev ?: isDevFunction(ctx)
+        rootDirectory = rootDirectory ?: PathMaster.defaultLocation(isDev)
+        val routeComponent = if (component.startsWith("<")) component else "<$component></$component>"
+        val allFiles = if (isDev == true) walkPaths() else cachedPaths
+        val resolver by lazy { if (isDev == true) VueDependencyResolver(allFiles,JavalinVue.vueAppName) else cachedDependencyResolver }
+        val componentId = routeComponent.removePrefix("<").takeWhile { it !in setOf('>', ' ') }
+        val dependencies = if (optimizeDependencies) resolver.resolve(componentId) else allFiles.joinVueFiles()
+        if (componentId !in dependencies) throw InternalServerErrorResponse("Route component not found: $routeComponent")
+        ctx.html(allFiles.find { it.endsWith("vue/layout.html") }!!.readText() // we start with the layout file
+                .inlineFiles(allFiles.filterNot { it.isVueFile() }) // we then inline css/js files
+                .replace("@componentRegistration", "@componentRegistration@serverState") // add @serverState anchor for later
+                .replace("@componentRegistration", dependencies) // add all dependencies
+                .replace("@serverState", getState(ctx, state)) // add the way too complex state
+                .replace("@routeComponent", routeComponent) // finally, add the route component itself
+                .replace("@cdnWebjar/", if (isDev == true) "/webjars/" else "https://cdn.jsdelivr.net/webjars/org.webjars.npm/")
+        ).header(Header.CACHE_CONTROL, cacheControl)
+    }
+}
+
+private fun Set<Path>.joinVueFiles() = this.filter { it.isVueFile() }.joinToString("") { "\n<!-- ${it.fileName} -->\n" + it.readText() }
+
+object FileInliner {
+    private val newlineRegex = Regex("\\r?\\n")
+    private val unconditionalRegex = Regex("""@inlineFile\(".*"\)""")
+    private val devRegex = Regex("""@inlineFileDev\(".*"\)""")
+    private val notDevRegex = Regex("""@inlineFileNotDev\(".*"\)""")
+
+    fun String.inlineFiles(nonVueFiles: List<Path>): String {
+        val pathMap = nonVueFiles.associateBy { """"/vue/${it.toString().replace("\\", "/").substringAfter("/vue/")}"""" } // normalize keys
+        return this.split(newlineRegex).joinToString("\n") { line ->
+            if (!line.contains("@inlineFile")) return@joinToString line // nothing to inline
+            val matchingKey = pathMap.keys.find { line.contains(it) } ?: throw IllegalStateException("Invalid path found: $line")
+            val matchingFileContent by lazy { Matcher.quoteReplacement(pathMap[matchingKey]!!.readText()) }
+            when {
+                devRegex.containsMatchIn(line) -> if (isDev == true) line.replace(devRegex, matchingFileContent) else ""
+                notDevRegex.containsMatchIn(line) -> if (isDev == false) line.replace(notDevRegex, matchingFileContent) else ""
+                else -> line.replace(unconditionalRegex, matchingFileContent)
+            }
+        }
+    }
+}
+
+internal fun getState(ctx: Context, state: Any?) = "\n<script>\n" +
+        "${prototypeOrGlobalConfig()}.\$javalin = JSON.parse(decodeURIComponent(\"${
+            urlEncodeForJavascript(JavalinJson.toJson(
+                    mapOf(
+                            "pathParams" to ctx.pathParamMap(),
+                            "queryParams" to ctx.queryParamMap(),
+                            "state" to (state ?: stateFunction(ctx))
+                    )
+            ))
+        }\"))\n</script>\n"
+
+// Unfortunately, Java's URLEncoder does not encode the space character in the same way as Javascript.
+// Javascript expects a space character to be encoded as "%20", whereas Java encodes it as "+".
+// All other encodings are implemented correctly, therefore we can simply replace the character in the encoded String.
+private fun urlEncodeForJavascript(string: String) = URLEncoder.encode(string, Charsets.UTF_8.name()).replace("+", "%20")
+private fun prototypeOrGlobalConfig() = if(JavalinVue.vueVersion == VueVersion.VUE_3) "${JavalinVue.vueAppName}.config.globalProperties" else "${JavalinVue.vueAppName}.prototype"
+internal fun Path.readText() = String(Files.readAllBytes(this))
+internal fun Path.isVueFile() = this.toString().endsWith(".vue")
diff --git a/javalin/src/main/java/io/javalin/plugin/rendering/vue/VueDependencyResolver.java b/javalin/src/main/java/io/javalin/plugin/rendering/vue/VueDependencyResolver.java
index ebe66db9..a88c2e28 100644
--- a/javalin/src/main/java/io/javalin/plugin/rendering/vue/VueDependencyResolver.java
+++ b/javalin/src/main/java/io/javalin/plugin/rendering/vue/VueDependencyResolver.java
@@ -31,13 +31,16 @@ public class VueDependencyResolver {
     private final Map<String, String> componentIdToOwnContent; // {component-id: component-content}
     private final Map<String, String> componentIdToDependencyContent; // {component-id: required-dependencies}
     private final Pattern tagRegex = Pattern.compile("<\\s*([a-z0-9|-]*).*?>", Pattern.DOTALL);
-    private final Pattern componentRegex = Pattern.compile("Vue.component\\s*\\(\\s*[\"|'](.*)[\"|']\\s*,.*");
+    private final Pattern componentRegex;
+    private final String appName;
 
-    public VueDependencyResolver(final Set<Path> paths) {
+    public VueDependencyResolver(final Set<Path> paths, String appVarName) {
+        appName = appVarName;
         componentIdToOwnContent = new HashMap<>();
         componentIdToDependencyContent = new HashMap<>();
-        paths.stream().filter(JavalinVueKt::isVueFile).forEach(path -> {
-            String fileContent = JavalinVueKt.readText(path);
+        componentRegex = Pattern.compile(appVarName + ".component\\s*\\(\\s*[\"|'](.*)[\"|']\\s*,.*");
+        paths.stream().filter(VueComponentKt::isVueFile).forEach(path -> {
+            String fileContent = VueComponentKt.readText(path);
             Matcher matcher = componentRegex.matcher(fileContent); // check for a vue component
             while (matcher.find()) {
                 componentIdToOwnContent.put(matcher.group(1), fileContent); // cache the file content, bound to the component name
@@ -54,7 +57,7 @@ public class VueDependencyResolver {
      */
     public String resolve(final String componentId) {
         if (!componentIdToOwnContent.containsKey(componentId)) {
-            throw new IllegalArgumentException(String.format("Component %s not found", componentId));
+            throw new IllegalArgumentException(String.format("Component %s not found in app %s", componentId, appName));
         }
         if (componentIdToDependencyContent.containsKey(componentId)) {
             return componentIdToDependencyContent.get(componentId);
@@ -63,7 +66,7 @@ public class VueDependencyResolver {
 
         StringBuilder builder = new StringBuilder();
         dependencies.forEach(dependency -> {
-            builder.append("<!-- ").append(dependency).append("-->\n");
+            builder.append("<!-- ").append(dependency).append(" -->\n");
             builder.append(componentIdToOwnContent.get(dependency));
             builder.append("\n");
         });
@@ -82,8 +85,8 @@ public class VueDependencyResolver {
     private Set<String> resolveTransitiveDependencies(final String componentId) {
         Set<String> requiredComponents = new HashSet<>();
         requiredComponents.add(componentId);// add it to the dependency list
-        Set<String> directDependencies = resolveDirectDependencies(componentId); //get its dependencies
-        requiredComponents.addAll(directDependencies); //add all its dependencies  to the required components list
+        Set<String> directDependencies = resolveDirectDependencies(componentId); // get its dependencies
+        requiredComponents.addAll(directDependencies); // add all its dependencies  to the required components list
         directDependencies.forEach(dependency -> {
             // resolve each dependency
             requiredComponents.addAll(resolveTransitiveDependencies(dependency));
@@ -100,11 +103,11 @@ public class VueDependencyResolver {
     private Set<String> resolveDirectDependencies(final String componentId) {
         Set<String> dependencies = new HashSet<>();
         String componentContent = componentIdToOwnContent.get(componentId);
-        Matcher matcher = tagRegex.matcher(componentContent); //match for HTML tags
+        Matcher matcher = tagRegex.matcher(componentContent); // match for HTML tags
         while (matcher.find()) {
             String match = matcher.group(1);
             if (!match.equals(componentId) && componentIdToOwnContent.containsKey(match)) { // if it isn't the component itself, and its in the component map
-                dependencies.add(match); //add it to the list of dependencies
+                dependencies.add(match); // add it to the list of dependencies
             }
         }
         return dependencies;


Test patch:
diff --git a/javalin/src/test/java/io/javalin/TestJavalinVue.kt b/javalin/src/test/java/io/javalin/TestJavalinVue.kt
index feafe4ac..4c122fbb 100644
--- a/javalin/src/test/java/io/javalin/TestJavalinVue.kt
+++ b/javalin/src/test/java/io/javalin/TestJavalinVue.kt
@@ -9,6 +9,7 @@ package io.javalin
 import io.javalin.http.Context
 import io.javalin.http.staticfiles.Location
 import io.javalin.plugin.rendering.vue.JavalinVue
+import io.javalin.plugin.rendering.vue.VueVersion
 import io.javalin.plugin.rendering.vue.VueComponent
 import io.javalin.testing.TestUtil
 import io.mockk.every
@@ -30,6 +31,8 @@ class TestJavalinVue {
 
     companion object {
         fun before() {
+            JavalinVue.vueAppName = "Vue"
+            JavalinVue.vueVersion = VueVersion.VUE_2
             JavalinVue.isDev = null // reset
             JavalinVue.stateFunction = { ctx -> mapOf<String, String>() } // reset
             JavalinVue.rootDirectory("src/test/resources/vue", Location.EXTERNAL) // src/main -> src/test
@@ -55,6 +58,7 @@ class TestJavalinVue {
         assertThat(res).contains(encodedState)
         assertThat(res).contains("""Vue.component("test-component", {template: "#test-component"});""")
         assertThat(res).contains("<body><test-component></test-component></body>")
+        assertThat(res).contains("Vue.prototype.\$javalin")
     }
 
     @Test
@@ -65,6 +69,35 @@ class TestJavalinVue {
         val res = http.getBody("/no-state")
         assertThat(res).contains(encodedEmptyState)
         assertThat(res).contains("<body><test-component></test-component></body>")
+        assertThat(res).contains("Vue.prototype.\$javalin")
+    }
+
+    @Test
+    fun `vue3 component without state`() = TestUtil.test { app, http ->
+        JavalinVue.vueVersion = VueVersion.VUE_3
+        JavalinVue.vueAppName("app")
+        val encodedEmptyState = """{"pathParams":{},"queryParams":{},"state":{}}""".uriEncodeForJavascript()
+
+        app.get("/no-state", VueComponent("<test-component-3></test-component-3>"))
+        val res = http.getBody("/no-state")
+        assertThat(res).contains(encodedEmptyState)
+        assertThat(res).contains("<body><test-component-3></test-component-3></body>")
+        assertThat(res).contains("app.config.globalProperties.\$javalin")
+}
+
+
+    @Test
+    fun `vue3 component with state`() = TestUtil.test { app, http ->
+        JavalinVue.vueVersion = VueVersion.VUE_3
+        JavalinVue.vueAppName("app")
+        val encodedState = """{"pathParams":{"my-param":"test-path-param"},"queryParams":{"qp":["test-query-param"]},"state":{"user":{"name":"tipsy","email":"tipsy@tipsy.tipsy"},"role":{"name":"Maintainer"}}}""".uriEncodeForJavascript()
+        JavalinVue.stateFunction = { ctx -> state }
+        app.get("/vue/:my-param", VueComponent("<test-component-3></test-component-3>"))
+        val res = http.getBody("/vue/test-path-param?qp=test-query-param")
+        assertThat(res).contains(encodedState)
+        assertThat(res).contains("""app.component("test-component-3", {template: "#test-component-3"});""")
+        assertThat(res).contains("<body><test-component-3></test-component-3></body>")
+        assertThat(res).contains("app.config.globalProperties.\$javalin")
     }
 
     @Test
@@ -141,7 +174,7 @@ class TestJavalinVue {
 
     @Test
     fun `setting rootDirectory with Path works`() = TestUtil.test { app, http ->
-        JavalinVue.rootDirectory(Paths.get("src/test/resources/vue"))
+        JavalinVue.rootDirectory = Paths.get("src/test/resources/vue")
         app.get("/path", VueComponent("test-component"))
         assertThat(http.getBody("/path")).contains("<test-component></test-component>")
     }
diff --git a/javalin/src/test/java/io/javalin/TestJavalinVueResolution.java b/javalin/src/test/java/io/javalin/TestJavalinVueResolution.java
index b5e1ef56..b6bbb248 100644
--- a/javalin/src/test/java/io/javalin/TestJavalinVueResolution.java
+++ b/javalin/src/test/java/io/javalin/TestJavalinVueResolution.java
@@ -18,6 +18,7 @@ package io.javalin;
 import io.javalin.http.staticfiles.Location;
 import io.javalin.plugin.rendering.vue.JavalinVue;
 import io.javalin.plugin.rendering.vue.VueComponent;
+import io.javalin.plugin.rendering.vue.VueVersion;
 import io.javalin.testing.TestUtil;
 import org.junit.Before;
 import org.junit.Test;
@@ -30,7 +31,7 @@ public class TestJavalinVueResolution {
 
     @Before
     public void resetJavalinVue() {
-        JavalinVue.rootDirectory("src/test/resources/vue", Location.EXTERNAL); // src/main -> src/test
+        TestJavalinVue.Companion.before();
         JavalinVue.optimizeDependencies = true;
     }
 
@@ -71,6 +72,35 @@ public class TestJavalinVueResolution {
         });
     }
 
+    @Test
+    public void resolveVue3DependencyTest() {
+        TestUtil.test((server, httpUtil) -> {
+            JavalinVue.vueVersion = VueVersion.VUE_3;
+            JavalinVue.vueAppName("app");
+            server.get("/single-view", new VueComponent("<view-one-3></view-one-3>"));
+            String body = httpUtil.getBody("/single-view");
+            assertThat(body).contains("<body><view-one-3></view-one-3></body>");
+            assertThat(body).doesNotContain("<view-two-3>");
+            assertThat(body).doesNotContain("<view-three-3>");
+            assertThat(body).doesNotContain("<view-nested-dependency-3>");
+            assertThat(body).doesNotContain("<view-two>");
+            assertThat(body).doesNotContain("<view-three>");
+            assertThat(body).doesNotContain("<view-nested-dependency>");
+            assertThat(body).contains("dependency-one");
+            assertThat(body).contains("dependency-one-3");
+            assertThat(body).doesNotContain("dependency-two");
+            assertThat(body).doesNotContain("dependency-three");
+            assertThat(body).doesNotContain("dependency-four");
+            assertThat(body).doesNotContain("nested-dependency");
+            assertThat(body).doesNotContain("dependency-two-3");
+            assertThat(body).doesNotContain("dependency-three");
+            assertThat(body).doesNotContain("dependency-four");
+            assertThat(body).doesNotContain("nested-dependency");
+            assertThat(body).doesNotContain("Vue.component");
+            assertThat(body).contains("app.component");
+        });
+    }
+
     @Test
     public void resolveNestedDependencyTest() {
         TestUtil.test((server, httpUtil) -> {
diff --git a/javalin/src/test/resources/vue/dependency-one-3.vue b/javalin/src/test/resources/vue/dependency-one-3.vue
new file mode 100644
index 00000000..9c1897c8
--- /dev/null
+++ b/javalin/src/test/resources/vue/dependency-one-3.vue
@@ -0,0 +1,6 @@
+<template id="dependency-one-3">
+    <div>Dependency One</div>
+</template>
+<script>
+    app.component('dependency-one-3',{template:"#dependency-one-3"});
+</script>
diff --git a/javalin/src/test/resources/vue/dependency-two-3.vue b/javalin/src/test/resources/vue/dependency-two-3.vue
new file mode 100644
index 00000000..58826689
--- /dev/null
+++ b/javalin/src/test/resources/vue/dependency-two-3.vue
@@ -0,0 +1,7 @@
+<template id="dependency-two-3">
+    <div>Dependency Two</div>
+</template>
+<script>
+    app.component('dependency-two-3',{template:"#dependency-two-3"});
+</script>
+
diff --git a/javalin/src/test/resources/vue/test-component-3.vue b/javalin/src/test/resources/vue/test-component-3.vue
new file mode 100644
index 00000000..ecf24f67
--- /dev/null
+++ b/javalin/src/test/resources/vue/test-component-3.vue
@@ -0,0 +1,7 @@
+<template id="test-component-3">
+    <div>Test ÆØÅ</div>
+</template>
+<script>
+    app.component("test-component-3", {template: "#test-component-3"});
+</script>
+
diff --git a/javalin/src/test/resources/vue/view-one-3.vue b/javalin/src/test/resources/vue/view-one-3.vue
new file mode 100644
index 00000000..5357756b
--- /dev/null
+++ b/javalin/src/test/resources/vue/view-one-3.vue
@@ -0,0 +1,7 @@
+<template id="view-one-3">
+    <dependency-one-3></dependency-one-3>
+</template>
+<script>
+    app.component("view-one-3",{template:"#view-one-3"})
+</script>
+