chore(stimulus): stimulus controllers can be lazy loaded

This commit is contained in:
Paul Chavard 2022-07-04 19:16:30 +02:00
parent 34d4bb5e93
commit f5e75d5e8e
4 changed files with 122 additions and 40 deletions

View file

@ -1,25 +1,22 @@
import { Controller } from '@hotwired/stimulus';
import { toggle, delegate } from '@utils';
import Highcharts from 'highcharts';
import Chartkick from 'chartkick';
export class ChartkickController extends Controller {
async connect() {
const Highcharts = await import('highcharts');
const Chartkick = await import('chartkick');
Chartkick.use(Highcharts);
const reflow = (nextChartId?: string) =>
nextChartId && Chartkick.charts[nextChartId]?.getChartObject()?.reflow();
delegate('click', '[data-toggle-chart]', (event) =>
toggleChart(event as MouseEvent, reflow)
toggleChart(event as MouseEvent)
);
}
}
function toggleChart(
event: MouseEvent,
reflow: (nextChartId?: string) => void
) {
Chartkick.use(Highcharts);
function reflow(nextChartId?: string) {
nextChartId && Chartkick.charts[nextChartId]?.getChartObject()?.reflow();
}
function toggleChart(event: MouseEvent) {
const nextSelectorItem = event.target as HTMLButtonElement,
chartClass = nextSelectorItem.dataset.toggleChart,
nextChart = chartClass

View file

@ -0,0 +1,5 @@
import { Controller } from '@hotwired/stimulus';
import 'trix';
import '@rails/actiontext';
export class TrixController extends Controller {}

View file

@ -1,8 +0,0 @@
import { Controller } from '@hotwired/stimulus';
export class TrixController extends Controller {
connect() {
import('trix');
import('@rails/actiontext');
}
}

View file

@ -1,30 +1,118 @@
import { Application } from '@hotwired/stimulus';
import invariant from 'tiny-invariant';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Loader = () => Promise<{ [key: string]: any }>;
const controllerAttribute = 'data-controller';
const controllers = import.meta.globEager('../controllers/*.{ts,tsx}');
const lazyControllers = import.meta.glob('../controllers/lazy/*.{ts,tsx}');
const controllerLoaders = new Map<string, Loader>();
export function registerControllers(application: Application) {
for (const [path, mod] of Object.entries(controllers)) {
const [filename] = path.split('/').reverse();
const name = filename
.replace(/_/g, '-')
.replace(/-controller\.(ts|tsx)$/, '');
if (name != 'application') {
if (mod.default) {
console.debug(`Registered default export for "${name}" controller`);
application.register(name, mod.default);
} else {
const exports = Object.entries(mod);
invariant(
exports.length == 1,
`Expected a single export but ${exports.length} exports were found for "${name}" controller`
);
const [exportName, exportMod] = exports[0];
console.debug(
`Registered named export "${exportName}" for "${name}" controller`
);
application.register(name, exportMod);
for (const [path, module] of Object.entries(controllers)) {
registerController(controllerName(path), module, application);
}
for (const [path, loader] of Object.entries(lazyControllers)) {
registerControllerLoader(controllerName(path), loader);
}
lazyLoadExistingControllers(application);
lazyLoadNewControllers(application);
}
function lazyLoadExistingControllers(
application: Application,
element?: Element
) {
queryControllerNamesWithin(element ?? application.element).forEach(
(controllerName) => loadController(controllerName, application)
);
}
function lazyLoadNewControllers(application: Application) {
new MutationObserver((mutationsList) => {
for (const { attributeName, target, type } of mutationsList) {
const element = target as Element;
switch (type) {
case 'attributes': {
if (
attributeName == controllerAttribute &&
element.getAttribute(controllerAttribute)
) {
extractControllerNamesFrom(element).forEach((controllerName) =>
loadController(controllerName, application)
);
}
break;
}
case 'childList':
lazyLoadExistingControllers(application, element);
break;
}
}
}).observe(application.element, {
attributeFilter: [controllerAttribute],
subtree: true,
childList: true
});
}
function queryControllerNamesWithin(element: Element) {
return Array.from(
element.querySelectorAll(`[${controllerAttribute}]`)
).flatMap(extractControllerNamesFrom);
}
function extractControllerNamesFrom(element: Element) {
return (
element
.getAttribute(controllerAttribute)
?.split(/\s+/)
.filter((content) => content.length) ?? []
);
}
function loadController(name: string, application: Application) {
const loader = controllerLoaders.get(name);
controllerLoaders.delete(name);
if (loader) {
loader()
.then((module) => registerController(name, module, application))
.catch((error) =>
console.error(`Failed to autoload controller: ${name}`, error)
);
}
}
function controllerName(path: string) {
const [filename] = path.split('/').reverse();
return filename.replace(/_/g, '-').replace(/-controller\.(ts|tsx)$/, '');
}
function registerController(
name: string,
module: Awaited<ReturnType<Loader>>,
application: Application
) {
if (module.default) {
console.debug(`Registered default export for "${name}" controller`);
application.register(name, module.default);
} else {
const exports = Object.entries(module);
invariant(
exports.length == 1,
`Expected a single export but ${exports.length} exports were found for "${name}" controller`
);
const [exportName, exportModule] = exports[0];
console.debug(
`Registered named export "${exportName}" for "${name}" controller`
);
application.register(name, exportModule);
}
}
function registerControllerLoader(name: string, loader: Loader) {
console.debug(`Registered loader for "${name}" controller`);
controllerLoaders.set(name, loader);
}