Skip to content

Commit

Permalink
Allow loading ESM bundles from URL (#7410)
Browse files Browse the repository at this point in the history
  • Loading branch information
philippjfr authored Oct 17, 2024
1 parent 0b59d53 commit f621f0c
Show file tree
Hide file tree
Showing 5 changed files with 98 additions and 20 deletions.
55 changes: 55 additions & 0 deletions doc/how_to/custom_components/esm/build.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ panel compile my_package.my_module my_package.subpackage.other_module

you will end up with a single `custom.bundle.js` file placed in the `my_package/dist` directory.

(build-dir)=
#### Using the `--build-dir` Option

The `--build-dir` option allows you to specify a custom directory where the `package.json` and raw JavaScript/JSX modules will be written. This is useful if you need to manually modify the dependencies before the bundling process and/or debug issues while bundling. To use this feature, follow these steps:
Expand Down Expand Up @@ -348,6 +349,8 @@ export function render() {

::::

#### Build

Once you have set up these three files you have to install the packages with `npm`:

```bash
Expand All @@ -361,3 +364,55 @@ esbuild confetti.js --bundle --format=esm --minify --outfile=ConfettiButton.bund
```

This will create a new file called `ConfettiButton.bundle.js`, which includes all the dependencies (even CSS, image files and other static assets if you have imported them).


#### Complex Bundles

If you want to bundle multiple components into a singular bundle and do not want to leverage the built-in compilation you can make do without specifying the `_esm` class variable entirely and always load the bundle directly. If you organize your Javascript/TypeScript/React code in the same way as described in the [--build-dir](#build-dir) section you can have a manual compilation workflow with all the benefits of automatic reload.

As an example let's say you have a module with multiple components:
```
panel_custom/
├── build/
├── index.js
├── package.json
├── <Component>.js<x>
└── <OtherComponent>.js<x>
├── __init__.py
├── components.py
```
Ensure that the `index.js` file exports each component:
::::{tab-set}
:::{tab-item} `JSComponent`
```javascript
import * as Component from "./Component"
import * as OtherComponent from "./OtherComponent"
export default {Component, OtherComponent}
```
:::
:::{tab-item} `ReactComponent`
A `ReactComponent` library MUST also export `React` and `createRoot`:
```javascript
import * as Component from "./Component"
import * as OtherComponent from "./OtherComponent"
import * as React from "react"
import {createRoot} from "react-dom/client"
export default {Component, OtherComponent, React, createRoot}
```
:::
::::
You can now develop your JS components as if it were a normal JS library. During the build step you would then run:
```bash
esbuild panel-custom/build/index.js --bundle --format=esm --minify --outfile=panel_custom/panel_custom.components.bundle.js
```
or replace `panel_custom.components.bundle.js` with the path specified on your component's `_bundle` attribute.
27 changes: 22 additions & 5 deletions panel/custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

from .config import config
from .io.datamodel import construct_data_model
from .io.resources import component_resource_path
from .io.state import state
from .models import (
AnyWidgetComponent as _BkAnyWidgetComponent,
Expand Down Expand Up @@ -235,7 +236,7 @@ def __init__(self, **params):

@classproperty
def _bundle_path(cls) -> os.PathLike | None:
if config.autoreload:
if config.autoreload and cls._esm:
return
try:
mod_path = pathlib.Path(inspect.getfile(cls)).parent
Expand Down Expand Up @@ -272,7 +273,7 @@ def _bundle_path(cls) -> os.PathLike | None:

@classmethod
def _esm_path(cls, compiled: bool = True) -> os.PathLike | None:
if compiled:
if compiled or not cls._esm:
bundle_path = cls._bundle_path
if bundle_path:
return bundle_path
Expand All @@ -295,8 +296,21 @@ def _esm_path(cls, compiled: bool = True) -> os.PathLike | None:

@classmethod
def _render_esm(cls, compiled: bool | Literal['compiling'] = True):
if (esm_path:= cls._esm_path(compiled=compiled is True)):
esm = esm_path.read_text(encoding='utf-8')
esm_path = cls._esm_path(compiled=compiled is True)
if esm_path:
if esm_path == cls._bundle_path and cls.__module__ in sys.modules:
base_cls = cls
for scls in cls.__mro__[1:][::-1]:
if not issubclass(scls, ReactiveESM):
continue
if esm_path == scls._esm_path(compiled=compiled is True):
base_cls = scls
esm = component_resource_path(base_cls, '_bundle_path', esm_path)
if config.autoreload:
modified = hashlib.sha256(str(esm_path.stat().st_mtime).encode('utf-8')).hexdigest()
esm += f'?{modified}'
else:
esm = esm_path.read_text(encoding='utf-8')
else:
esm = cls._esm
esm = textwrap.dedent(esm)
Expand Down Expand Up @@ -360,7 +374,10 @@ def _init_params(self) -> dict[str, Any]:
bundle_path = self._bundle_path
importmap = self._process_importmap()
if bundle_path:
bundle_hash = hashlib.sha256(str(bundle_path).encode('utf-8')).hexdigest()
if bundle_path == self._esm_path(not config.autoreload) and cls.__module__ in sys.modules:
bundle_hash = 'url'
else:
bundle_hash = hashlib.sha256(str(bundle_path).encode('utf-8')).hexdigest()
else:
bundle_hash = None
data_props = self._process_param_change(data_params)
Expand Down
2 changes: 1 addition & 1 deletion panel/io/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -596,7 +596,7 @@ class ComponentResourceHandler(StaticFileHandler):

_resource_attrs = [
'__css__', '__javascript__', '__js_module__', '__javascript_modules__', '_resources',
'_css', '_js', 'base_css', 'css', '_stylesheets', 'modifiers'
'_css', '_js', 'base_css', 'css', '_stylesheets', 'modifiers', '_bundle_path'
]

def initialize(self, path: Optional[str] = None, default_filename: Optional[str] = None):
Expand Down
13 changes: 5 additions & 8 deletions panel/models/react_component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export class ReactComponentView extends ReactiveESMView {

protected override _render_code(): string {
let render_code = `
if (rendered && view.model.usesReact) {
if (rendered) {
view._changing = true
const root = createRoot(view.container)
try {
Expand All @@ -44,9 +44,10 @@ if (rendered && view.model.usesReact) {
}
}`
let import_code
const cache_key = (this.model.bundle === "url") ? this.model.esm : (this.model.bundle || `${this.model.class_name}-${this.model.esm.length}`)
if (this.model.bundle) {
import_code = `
const ns = await view._module_cache.get(view.model.bundle)
const ns = await view._module_cache.get("${cache_key}")
const {React, createRoot} = ns.default`
} else {
import_code = `
Expand All @@ -56,7 +57,7 @@ import { createRoot } from "react-dom/client"`
if (this.model.usesMui) {
if (this.model.bundle) {
import_code = `
const ns = await view._module_cache.get(view.model.bundle)
const ns = await view._module_cache.get("${cache_key}")
const {CacheProvider, React, createCache, createRoot} = ns.default`
} else {
import_code = `
Expand All @@ -67,7 +68,7 @@ import { CacheProvider } from "@emotion/react"`
render_code = `
if (rendered) {
const cache = createCache({
key: 'css-${btoa(this.model.id).replace("=", "-").toLowerCase()}',
key: 'css-${this.model.id.replace("-", "").replace(/\d/g, (digit) => String.fromCharCode(digit.charCodeAt(0) + 49)).toLowerCase()}',
prepend: true,
container: view.style_cache,
})
Expand Down Expand Up @@ -248,10 +249,6 @@ export class ReactComponent extends ReactiveESM {
return false
}

get usesReact(): boolean {
return this.compiled !== null && this.compiled.includes("React")
}

override compile(): string | null {
const compiled = super.compile()
if (this.bundle) {
Expand Down
21 changes: 15 additions & 6 deletions panel/models/reactive_esm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -630,17 +630,26 @@ export class ReactiveESM extends HTMLBox {
this.compiled = compiled
this._declare_importmap()
let esm_module
const cache_key = this.bundle || `${this.class_name}-${this.esm.length}`
const use_cache = (!this.dev || this.bundle)
const cache_key = (this.bundle === "url") ? this.esm : (this.bundle || `${this.class_name}-${this.esm.length}`)
let resolve: (value: any) => void
if (!this.dev && MODULE_CACHE.has(cache_key)) {
if (use_cache && MODULE_CACHE.has(cache_key)) {
esm_module = Promise.resolve(MODULE_CACHE.get(cache_key))
} else {
if (!this.dev) {
if (use_cache) {
MODULE_CACHE.set(cache_key, new Promise((res) => { resolve = res }))
}
const url = URL.createObjectURL(
new Blob([this.compiled], {type: "text/javascript"}),
)
let url
if (this.bundle === "url") {
const parts = location.pathname.split("/")
let path = parts.slice(0, parts.length-1).join("/")
if (path.length) {
path += "/"
}
url = `${location.origin}/${path}${this.esm}`
} else {
url = URL.createObjectURL(new Blob([this.compiled], {type: "text/javascript"}))
}
esm_module = (window as any).importShim(url)
}
this.compiled_module = (esm_module as Promise<any>).then((mod: any) => {
Expand Down

0 comments on commit f621f0c

Please sign in to comment.