Skip to content

Commit

Permalink
Merge pull request #52 from joekrump/experiment-with-dynamic-imports-…
Browse files Browse the repository at this point in the history
…of-all-vue-components

Experiment with dynamic imports of all vue components
  • Loading branch information
joekrump authored Jul 16, 2023
2 parents 28c5169 + 7cf05a0 commit 8451d41
Show file tree
Hide file tree
Showing 13 changed files with 180 additions and 66 deletions.
16 changes: 4 additions & 12 deletions app/frontend/components/TheWelcome.vue
Original file line number Diff line number Diff line change
@@ -1,15 +1,7 @@
<script setup lang="ts">
import WelcomeItem from "./WelcomeItem.vue";
import DocumentationIcon from "./icons/IconDocumentation.vue";
import ToolingIcon from "./icons/IconTooling.vue";
import EcosystemIcon from "./icons/IconEcosystem.vue";
import CommunityIcon from "./icons/IconCommunity.vue";
</script>

<template>
<WelcomeItem>
<template #icon>
<DocumentationIcon />
<IconDocumentation />
</template>
<template #heading>Documentation</template>

Expand Down Expand Up @@ -64,7 +56,7 @@ import CommunityIcon from "./icons/IconCommunity.vue";

<WelcomeItem>
<template #icon>
<ToolingIcon />
<IconTooling />
</template>
<template #heading>Tooling</template>

Expand Down Expand Up @@ -101,7 +93,7 @@ import CommunityIcon from "./icons/IconCommunity.vue";

<WelcomeItem>
<template #icon>
<EcosystemIcon />
<IconEcosystem />
</template>
<template #heading>Ecosystem</template>

Expand Down Expand Up @@ -158,7 +150,7 @@ import CommunityIcon from "./icons/IconCommunity.vue";

<WelcomeItem>
<template #icon>
<CommunityIcon />
<IconCommunity />
</template>
<template #heading>Community</template>

Expand Down
51 changes: 24 additions & 27 deletions app/frontend/entrypoints/turbo-vue.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
import { getVueComponents } from "@/helpers/routes";
import { createApp, type App, type Component } from "vue";
import type { App } from "vue";

let components: App[] = [];
let mountedApps: Array<App | undefined> = [];

const mountApp = async (e: Event) => {
const vueComponentsForPage = getVueComponents(window.location.pathname);
let app: App;
const mountVueComponents = async (e: Event) => {
const vueAppsForPage = getVueComponents(window.location.pathname);
let nodeToMountOn: HTMLElement;
let props: string | undefined;

if (vueComponentsForPage === undefined) {
if (vueAppsForPage === undefined) {
return;
}

Expand All @@ -18,20 +16,13 @@ const mountApp = async (e: Event) => {
) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const rootContainer = entry.target;
const component = vueComponentsForPage.find(
(c) => c[0] === `#${rootContainer.id}`
);
const rootContainer = entry.target as HTMLElement;
const asyncAppLoader = vueAppsForPage[`#${rootContainer.id}`];

if (component !== undefined) {
component[1]()
.then((c: Component) => {
console.debug(c);
props = rootContainer.dataset.props;
app = createApp(c, props ? JSON.parse(props) : undefined);
components.push(app);
app.mount(rootContainer);
localStorage.removeItem("dynamic import failed count");
if (asyncAppLoader !== undefined) {
asyncAppLoader()
.then((mountApp) => {
mountedApps.push(mountApp());
})
.catch((error: Error) => {
if (
Expand Down Expand Up @@ -59,8 +50,10 @@ const mountApp = async (e: Event) => {
threshold: 0.1,
});

for (const [rootContainer] of vueComponentsForPage) {
nodeToMountOn = (e.currentTarget as Document)?.querySelector(rootContainer);
for (const elementIdSelector in vueAppsForPage) {
nodeToMountOn = (e.currentTarget as Document)?.querySelector(
elementIdSelector
) as HTMLElement;

if (nodeToMountOn !== null) {
observer.observe(nodeToMountOn);
Expand All @@ -70,15 +63,19 @@ const mountApp = async (e: Event) => {
}
};

document.addEventListener("turbo:load", mountApp);
// Mount Vue components when Turbo has finished loading a view.
// Find details about the turbo:load event here: https://turbo.hotwired.dev/reference/events
document.addEventListener("turbo:load", mountVueComponents);

// Unmount Vue components when there is a requested navigation to a new page.
// Find details about the turbo:visit event here: https://turbo.hotwired.dev/reference/events
document.addEventListener("turbo:visit", () => {
if (components.length > 0) {
components.forEach((app) => {
app.unmount();
if (mountedApps.length > 0) {
mountedApps.forEach((app) => {
app?.unmount();
});

components = [];
mountedApps = [];
}
});

Expand Down
8 changes: 8 additions & 0 deletions app/frontend/entrypoints/views/pandas/index/mount-apps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import PandaInfo from "@/entrypoints/views/pandas/index/PandaInfo.vue";
import Zap from "@/entrypoints/views/pandas/index/Zap.vue";
import { mountComponent } from "@/helpers/mount-component";

const mountPandasInfo = () => mountComponent("#pandas-view", PandaInfo);
const mountZap = () => mountComponent("#lazy-load", Zap);

export { mountPandasInfo, mountZap };
File renamed without changes.
16 changes: 16 additions & 0 deletions app/frontend/entrypoints/views/root/mount-apps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import RootApp from "@/entrypoints/views/root/RootApp.vue";
import { mountComponent } from "@/helpers/mount-component";

const mountRootApp = () => {
const componentDependencies = {
WelcomeItem: () => import("@/components/WelcomeItem.vue"),
IconDocumentation: () => import("@/components/icons/IconDocumentation.vue"),
IconTooling: () => import("@/components/icons/IconTooling.vue"),
IconEcosystem: () => import("@/components/icons/IconEcosystem.vue"),
IconCommunity: () => import("@/components/icons/IconCommunity.vue"),
};

return mountComponent("#root-view", RootApp, componentDependencies);
};

export { mountRootApp };
36 changes: 36 additions & 0 deletions app/frontend/helpers/mount-component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { AsyncComponentLoader, Component } from "vue";
import { createApp, type App, defineAsyncComponent } from "vue";

export function mountComponent(
querySelector: string,
component: Component,
componentDependencies?: Record<string, AsyncComponentLoader<Component>>
) {
const rootContainer = document.querySelector(querySelector) as HTMLElement;
let app: App | undefined;

if (rootContainer !== null) {
const props = rootContainer.dataset.props;
app = createApp(component, props ? JSON.parse(props) : undefined);

if (componentDependencies !== undefined) {
globallyRegisterComponentsOnApp(app, componentDependencies);
}

app.mount(rootContainer);
localStorage.removeItem("dynamic import failed count");
} else {
console.error("No container found for Vue component: ", querySelector);
}

return app;
}

function globallyRegisterComponentsOnApp(
app: App,
components: Record<string, AsyncComponentLoader<Component>>
) {
for (const [name, asyncComponentLoader] of Object.entries(components)) {
app.component(name, defineAsyncComponent(asyncComponentLoader));
}
}
5 changes: 5 additions & 0 deletions app/frontend/helpers/mount-component.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { App } from "vue";

type AsyncAppMounter = () => Promise<() => App | undefined>;

export type { AsyncAppMounter }
37 changes: 24 additions & 13 deletions app/frontend/helpers/routes.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,30 @@
const RootApp = async () =>
(await import("@/entrypoints/views/root/App.vue")).default;
const Zap = async () =>
(await import("@/entrypoints/views/pandas/index/Zap.vue")).default;
const PandasApp = async () =>
(await import("@/entrypoints/views/pandas/index/App.vue")).default;
import type { AsyncAppMounter } from "@/helpers/mount-component.types";

const routes = {
"/": [["#root-view", RootApp]],
"/pandas": [
["#pandas-view", PandasApp],
["#lazy-load", Zap],
],
const mountRootApp = async () =>
(await import("@/entrypoints/views/root/mount-apps")).mountRootApp;
const mountPandasZap = async () =>
(await import("@/entrypoints/views/pandas/index/mount-apps")).mountZap;
const mountPandasInfo = async () =>
(await import("@/entrypoints/views/pandas/index/mount-apps")).mountPandasInfo;

const routes: {
[routePath: string]: {
[elementIdSelector: string]: AsyncAppMounter;
};
} = {
"/": {
"#root-view": mountRootApp,
},
// new route entry here
"/pandas": {
"#pandas-view": mountPandasInfo,
"#lazy-load": mountPandasZap,
},
};

export const getVueComponents = (url: string) => {
export const getVueComponents = (
url: string
): { [elementIdSelector: string]: AsyncAppMounter } => {
const pattern = /^\/((?:[^\/]+\/)*[^\/]+)\/\d+(\/\w+)?$/;
const result = url.match(pattern);

Expand Down
17 changes: 17 additions & 0 deletions lib/generators/view_with_vue/templates/mount-apps.ts.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import <%= capitalize_name %> from "@/entrypoints/views/<%= route_path %>/<%= capitalize_name %>.vue";
import { mountComponent } from "@/helpers/mount-component";

const mount<%= capitalize_name %> = () => {
// optional: add any components that this app depends on here. They will be
// globally available to this app.
//
// Example:
// const componentDependencies = {
// WelcomeItem: () => import("@/components/WelcomeItem.vue"),
// };
// return mountComponent("#vue-root", <%= capitalize_name %>, componentDependencies);

return mountComponent("#vue-root", <%= capitalize_name %>);
};

export { mount<%= capitalize_name %> };
22 changes: 16 additions & 6 deletions lib/generators/view_with_vue/view_with_vue_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ def generate_files
say 'Creating .html.erb file', '✅'
template 'view_with_vue.html.erb', "app/views/#{route_path}/#{name}.html.erb"
say 'Creating the .vue file', '✅'
template 'App.vue.erb', "app/frontend/entrypoints/views/#{route_path}/#{name.capitalize}.vue"
template 'App.vue.erb', "app/frontend/entrypoints/views/#{route_path}/#{capitalize_name}.vue"
say 'Creating the .ts file for dynamically mounting the Vue component', '✅'
template 'mount-apps.ts.erb', "app/frontend/entrypoints/views/#{route_path}/mount-apps.ts"
end

def modify_file
Expand All @@ -41,23 +43,31 @@ def vue_import_name
snake_case_to_title_case("#{route_path.sub('/', '_')}_#{name}")
end

def capitalize_name
return @capitalize_name if defined?(@capitalize_name)

@capitalize_name = name.split('_').map(&:capitalize).join('')
end

def add_route_helper_entry
route_helper_path = 'app/frontend/helpers/routes.ts'
import_entry = <<~CODE
const #{vue_import_name} = async () =>
(await import("@/entrypoints/views/#{route_path}/#{name.capitalize}.vue")).default;
const mount#{capitalize_name} = async () =>
(await import("@/entrypoints/views/#{route_path}/mount-apps")).mount#{capitalize_name};
CODE

result = route_declaration.match(/get '(.*?)'/)
route_path = result[1] if result

route_entry = <<~CODE
\s\s"#{route_path}": [["#vue-root", #{vue_import_name}]],
\s\s"#{route_path}": {
\s\s"#vue-root": mount#{capitalize_name}
\s\s},
CODE

insert_into_file route_helper_path, import_entry, before: /const routes = \{\n/
insert_into_file route_helper_path, route_entry, after: /const routes = \{\n/
insert_into_file route_helper_path, import_entry, before: /const routes: \{\n/
insert_into_file route_helper_path, route_entry, after: /\/\/ new route entry here\n/
end

def add_controller_method
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,27 @@
const RootApp = async () =>
(await import("@/entrypoints/views/root/App.vue")).default;
const mountRootApp = async () =>
(await import("@/entrypoints/views/root/mount-apps").mountRootApp);

const routes = {
"/": [["#root-view", RootApp]],

const routes: {
[routePath: string]: {
[elementIdSelector: string]: AsyncAppMounter;
};
} = {
"/": {
"#root-view": mountRootApp,
},
// new route entry here
};

export const getVueComponents = (url: string) => routes[url];
export const getVueComponents = (
url: string
): { [elementIdSelector: string]: AsyncAppMounter } => {
const pattern = /^\/((?:[^\/]+\/)*[^\/]+)\/\d+(\/\w+)?$/;
const result = url.match(pattern);

if (result !== null) {
url = result[2] ? `/${result[1]}/:id${result[2]}` : `/${result[1]}/:id`;
}

return routes[url];
};
9 changes: 6 additions & 3 deletions test/lib/generators/view_with_vue_generator_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,12 @@ def generator_output_path
assert_file(
"#{generator_output_path}/app/frontend/helpers/routes.ts"
) do |content|
assert_match 'const AnimalsPandasIndex = async () =>', content
assert_match ' (await import("@/entrypoints/views/animals/pandas/Index.vue")).default;', content
assert_match ' "/animals/pandas": [["#vue-root", AnimalsPandasIndex]],', content
assert_match 'const mountIndex = async () =>', content
assert_match ' (await import("@/entrypoints/views/animals/pandas/mount-apps")).mountIndex;', content

assert_match '"/animals/pandas": {', content
assert_match '"#vue-root": mountIndex', content
assert_match '}', content
end
end
end
Expand Down

0 comments on commit 8451d41

Please sign in to comment.