holoviz/panel · #8848

Fix notebook resources on RequireJS pages when the public CDNs are unreachable

philippjfr · merged Oct 13, 20267 files · 487 + / 96 −
panel/_templates/autoload_panel_js.js135 + / 2 −
@@ -39,6 +39,70 @@ calls it with the rendered model.     return !live_notebook && index !== -1 ? CDN_DIST + url.slice(index + JUPYTER_EXTENSION_PATH.length) : url;   } +  // Pending until this bootstrap has loaded everything (or given up), so+  // that output rendered meanwhile can wait for Panel's models.+  let bootstrap_resolve;+  const bootstrap = new Promise((resolve) => { bootstrap_resolve = resolve });+  root.__panel_bootstrap__ = bootstrap;+  function bootstrap_done() {+    if (root.__panel_bootstrap__ === bootstrap) {+      root.__panel_bootstrap__ = null;+    }+    bootstrap_resolve();+  }+  setTimeout(bootstrap_done, Math.max({{ timeout|default(0)|json }}, 15000));++  // jupyter_bokeh, which renders Panel output for comms='ipywidgets' and in+  // VS Code, builds a view as soon as its output arrives, so a notebook run+  // top to bottom can render before BokehJS has loaded or before panel.js+  // has registered the models the view needs. Both the classic notebook and+  // VS Code load jupyter_bokeh through RequireJS, so its fetch is held until+  // the bootstrap is done, and should it already be loaded, ipywidgets+  // awaits render(), which is held instead.+  function hold_bokeh_views(module) {+    const View = module?.BokehView;+    if (View == null || View.prototype.__panel_hold__ === true) {+      return;+    }+    const render = View.prototype.render;+    View.prototype.render = function(...args) {+      const pending = root.__panel_bootstrap__;+      return pending == null ? render.apply(this, args) : pending.then(() => render.apply(this, args));+    };+    View.prototype.__panel_hold__ = true;+  }+  if (root.requirejs != null) {+    for (const name of ["@bokeh/jupyter_bokeh", "nbextensions/jupyter_bokeh/index"]) {+      if (root.requirejs.defined(name)) {+        hold_bokeh_views(root.requirejs(name));+      }+    }+    const load = root.requirejs.load;+    if (load?.__panel_hold__ !== true) {+      root.requirejs.load = function(context, name, url) {+        const pending = root.__panel_bootstrap__;+        if (pending != null && name.includes("jupyter_bokeh")) {+          pending.then(() => load.call(this, context, name, url));+          return;+        }+        return load.call(this, context, name, url);+      };+      root.requirejs.load.__panel_hold__ = true;+    }+    const on_resource_load = root.requirejs.onResourceLoad;+    if (on_resource_load?.__panel_hold__ !== true) {+      // Called once a module is defined but before it is handed to the+      // widget manager that asked for it.+      root.requirejs.onResourceLoad = function(context, map, deps) {+        if (map.id.includes("jupyter_bokeh")) {+          hold_bokeh_views(context.defined[map.id]);+        }+        return on_resource_load?.apply(this, arguments);+      };+      root.requirejs.onResourceLoad.__panel_hold__ = true;+    }+  }+   // Set a timeout for this load but only if we are not already initializing   if (typeof (root._bokeh_timeout) === "undefined" || (force || !root._bokeh_is_initializing)) {     root._bokeh_timeout = Date.now() + {{ timeout|default(0)|json }};@@ -132,8 +196,57 @@ calls it with the rendered model.       return true;     } +    // Mirrors the AMD guard in panel/models/resources.ts: a UMD library that+    // is not loaded by RequireJS must take its browser-global branch, so+    // define.amd reads as undefined while one of these scripts executes. A+    // module script has no currentScript, so it is told apart by the+    // directory its code was loaded from.+    function install_amd_guard() {+      const define = root.define;+      if (typeof define !== "function" || define.amd == null || define.__panel_no_amd__ === true) {+        return;+      }+      const dirs = root.__panel_no_amd_dirs__ = root.__panel_no_amd_dirs__ || [];+      const hidden = () => {+        const script = document.currentScript;+        if (script != null) {+          return script.hasAttribute("data-panel-no-amd");+        }+        const stack = new Error().stack || "";+        return dirs.some((dir) => stack.includes(dir));+      };+      let amd = define.amd;+      try {+        Object.defineProperty(define, "amd", {+          configurable: true,+          enumerable: true,+          get: () => hidden() ? undefined : amd,+          set: (value) => { amd = value },+        });+        Object.defineProperty(define, "__panel_no_amd__", {value: true});+      } catch (e) {+        console.warn(`Panel: could not hide RequireJS from component libraries: ${e}`);+      }+    }++    function hide_amd(element) {+      element.setAttribute("data-panel-no-amd", "");+      install_amd_guard();+    }++    function hide_amd_module(url) {+      const dirs = root.__panel_no_amd_dirs__ = root.__panel_no_amd_dirs__ || [];+      const dir = new URL(url, document.baseURI).href.split("?")[0].replace(/[^/]*$/, "");+      // panel.js itself lives in a dist root, and must never match.+      if (!/\/(dist|static\/extensions\/panel)\/$/.test(dir) && !dirs.includes(dir)) {+        dirs.push(dir);+      }+      install_amd_guard();+    }+     function inject_script_tag(url) {       const element = document.createElement('script');+      hide_amd(element);       element.onload = on_load;       element.onerror = () => {         if (!fallback_to_cdn(element, url, "src", document.head)) {@@ -152,8 +265,15 @@ calls it with the rendered model.     root._bokeh_is_loading = 1;     if (window.requirejs) {       const require_config = {{ config|conffilter }};+      // RequireJS tries each path in turn, so a bundled copy the Jupyter+      // extension cannot serve falls back to the CDN, then to the original.+      const require_urls = (url) => {+        url = resource_url(url);+        const index = url.indexOf(JUPYTER_EXTENSION_PATH);+        return index === -1 ? [url] : [url, CDN_DIST + url.slice(index + JUPYTER_EXTENSION_PATH.length)];+      };       for (const [name, path] of Object.entries(require_config.paths || {})) {-        require_config.paths[name] = Array.isArray(path) ? path.map(resource_url) : resource_url(path);+        require_config.paths[name] = (Array.isArray(path) ? path : [path]).flatMap(require_urls);       }       window.requirejs.config(require_config);       {% if requirements %}@@ -165,6 +285,11 @@ calls it with the rendered model.       define("{{ r }}{{ global_suffix }}", ["{{ r }}"], function(module) {         const name = "{{ exports[r] }}"         const existing = window[name]+        // A library that never calls define() (plotly.js) resolves to+        // undefined but has assigned its global itself.+        if (module == null) {+          return existing+        }         // Several packages can contribute to one namespace (deck.gl's core,         // json and carto bundles all publish `deck`), so merge into a fresh         // object rather than overwrite.@@ -288,6 +413,7 @@ calls it with the rendered model.         continue;       }       var element = document.createElement('script');+      hide_amd_module(resource_url(url));       root._bokeh_is_loading++;       element.onerror = () => {         if (!fallback_to_cdn(element, url, "src", document.head)) {@@ -338,7 +464,10 @@ calls it with the rendered model.     {%- endfor %}     {%- for js in (bundle.js_raw if bundle else js_raw) %}     function(Bokeh, define, module, exports) {-      {{ js|indent(6) }}+      {# No indent filter: it splits on every character Python's splitlines()+         treats as a line break, which minified libraries (xterm.js) carry+         inside their string literals. #}+{{ js }}     },     {% endfor -%}     function(Bokeh, define, module, exports) {} // ensure no trailing comma for IE@@ -413,11 +542,15 @@ calls it with the rendered model.           }         }       }+      // Views waiting on this run once the current task is done, by which+      // point the load callback has also restored root.Bokeh.+      bootstrap_done();     } else if (Date.now() < root._bokeh_timeout) {       setTimeout(run_inline_js, 100);     } else if (!root._bokeh_failed_load) {       console.log("Bokeh: BokehJS failed to load within specified timeout.");       root._bokeh_failed_load = true;+      bootstrap_done();     }     root._bokeh_is_initializing = false;   }
panel/io/notebook.py105 + / 27 −
@@ -44,7 +44,7 @@ from .model import add_to_doc, diff from .resources import (     CDN_DIST, DIST_DIR, PANEL_DIR, Resources, _env, bundle_resources,-    patch_model_css, set_default_resource_mode, set_resource_mode,+    is_cdn_url, patch_model_css, set_default_resource_mode, set_resource_mode, ) from .state import state @@ -348,46 +348,102 @@ def _cdn_url_key(url):     return url[:-3] if url.endswith('.js') else url  -def _require_covered_urls(model, model_require, resources=None):+def _bundled_require_path(path_key, raw_key, url):     """-    Resolved urls of a model's scripts that RequireJS is going to load.+    The RequireJS path for a bundled file, given the keys of the path it+    replaces and of the resource it was bundled from, or None if the two+    name different files.+    """+    stem = url[:-3] if url.endswith('.js') else None+    if raw_key == path_key:+        # RequireJS appends `.js` to every path unless it has a query, so a+        # bundled file without that extension needs an (empty) one.+        return stem if stem is not None else f'{url}?'+    if path_key.startswith(f'{raw_key}/'):+        # A resource url may name the package rather than a file in it+        # (``vega@6.1.2``, which the cdn resolves to the package default),+        # while a RequireJS path always names the file+        # (``vega@6.1.2/build/vega.min``). Both refer to the same library.+        return stem if stem is not None else f'{url}?'+    rest = raw_key[len(path_key):]+    if raw_key.startswith(f'{path_key}/') and stem is not None and stem.endswith(rest):+        # A path naming a directory (``ace`` resolving ``ace/ace``) maps to+        # the directory the bundled files live in.+        return stem[:-len(rest)]+    return None -    Only those may be skipped: leaving a covered url in would fetch it-    twice and corrupt RequireJS' resolution, while skipping an uncovered-    one would mean nothing loads it at all.++def _require_bundled_paths(model, paths, resources=None):+    """+    Matches a model's RequireJS paths against its bundled resources.++    Returns the resolved urls of the bundled scripts RequireJS is going to+    load, which must not also get a script tag (that would fetch them twice+    and corrupt RequireJS' resolution), and for each matched path the url+    of its bundled copy. Pointing RequireJS at the bundled copy is what+    keeps the notebook working where the public npm CDNs are unreachable.     """-    paths = model_require.get('paths', {}) or {}-    path_keys = set()-    for value in paths.values():+    path_keys = {}+    for name, value in (paths or {}).items():         for url in (value if isinstance(value, (list, tuple)) else (value,)):             if isinstance(url, str):-                path_keys.add(_cdn_url_key(url))+                path_keys.setdefault(name, _cdn_url_key(url))     if not path_keys:-        return []+        return [], {}      try:         raw = list(getattr(model, '__javascript_raw__', None) or [])         declared = list(getattr(model, '__javascript__', None) or [])     except Exception:-        return []+        return [], {}     if len(raw) != len(declared):-        return []+        return [], {}      resolved = resources.adjust_paths(declared) if resources is not None else declared-    covered = []-    for raw_url, url in zip(raw, resolved):+    covered, bundled = [], {}+    for raw_url, declared_url, url in zip(raw, declared, resolved):         if not isinstance(raw_url, str):             continue         raw_key = _cdn_url_key(raw_url)-        # A resource url may name the package rather than a file in it-        # (``vega@6.1.2``, which the cdn resolves to the package default),-        # while a RequireJS path always names the file-        # (``vega@6.1.2/build/vega.min``). Both refer to the same library.-        if raw_key in path_keys or any(-            key.startswith(f'{raw_key}/') for key in path_keys-        ):+        matched = False+        for name, path_key in path_keys.items():+            path = _bundled_require_path(path_key, raw_key, url)+            if path is None:+                continue+            matched = True+            if name not in bundled and declared_url != raw_url:+                bundled[name] = (path, declared_url)+        if matched:             covered.append(url)-    return covered+    return covered, bundled+++def _strip_require(model_require, names):+    """+    Removes paths from a RequireJS config, along with their exports, shims+    and any shim dependencies on them.+    """+    removed = set()+    paths = {}+    for key, value in model_require.get('paths', {}).items():+        if key in names:+            removed.update(key[1] if isinstance(key, tuple) else (key,))+        else:+            paths[key] = value+    model_require['paths'] = paths+    if 'exports' in model_require:+        model_require['exports'] = {+            k: v for k, v in model_require['exports'].items() if k not in removed+        }+    if 'shim' in model_require:+        shim = {}+        for name, conf in model_require['shim'].items():+            if name in removed:+                continue+            if conf.get('deps'):+                conf = dict(conf, deps=[d for d in conf['deps'] if d not in removed])+            shim[name] = conf+        model_require['shim'] = shim   def _resolve_js_skip(skip, resources=None):@@ -418,12 +474,15 @@ def require_components(resources=None):     let panel.io.resource_spec derive the rest.      ``resources``, if given, resolves the ``__js_skip__`` urls so they-    match what the bundle emits.+    match what the bundle emits, and points RequireJS at Panel's bundled+    copies of the libraries rather than at the npm CDN, or drops them from+    RequireJS altogether when they are inlined.     """     from ..config import config, panel_extension      configs, requirements, exports = [], [], {}     js_requires = []+    inline = resources is not None and resources.mode == 'inline'     active_modules = tuple(         panel_extension._imports[extension]         for extension in panel_extension._loaded_extensions@@ -464,9 +523,28 @@ def require_components(resources=None):             model_require = model         else:             model_require = dict(model.__js_require__)-            for url in _require_covered_urls(model, model_require, resources):-                if url not in require_skip:-                    require_skip.append(url)+            paths = dict(model_require.get('paths', {}))+            covered, bundled = _require_bundled_paths(model, paths, resources)+            if inline:+                # The inlined copy already assigned the global, since the+                # bootstrap shadows `define` for inline code, so RequireJS+                # would only fetch the library a second time.+                _strip_require(model_require, {+                    name for name, (_, url) in bundled.items() if is_cdn_url(url)+                })+                if not model_require['paths']:+                    continue+            else:+                for name, (path, _) in bundled.items():+                    original = paths[name]+                    fallbacks = list(original) if isinstance(original, (list, tuple)) else [original]+                    # The original url stays as a fallback for when the+                    # bundled copy is unavailable.+                    paths[name] = [path, *(url for url in fallbacks if url != path)]+                model_require['paths'] = paths+                for url in covered:+                    if url not in require_skip:+                        require_skip.append(url)          model_exports = model_require.pop('exports', {})         if not any(model_require == config for config in configs):
panel/models/deckgl.py4 + / 31 −
@@ -61,37 +61,10 @@ def __js_skip__(cls):             'CartoLibrary': cls.__javascript__[8:],         } -    __js_require__ = {-        'paths': {-            "h3": f"{config.npm_cdn}/h3-js@4.1.0/dist/h3-js.umd",-            "deck-gl": f"{config.npm_cdn}/deck.gl@{DECKGL_VERSION}/dist.min",-            "deck-json": f"{config.npm_cdn}/@deck.gl/json@{DECKGL_VERSION}/dist.min",-            "loader-csv": f"{config.npm_cdn}/@loaders.gl/csv@4.2.2/dist/dist.min",-            "loader-json": f"{config.npm_cdn}/@loaders.gl/json@4.2.2/dist/dist.min",-            "loader-tiles": f"{config.npm_cdn}/@loaders.gl/3d-tiles@4.2.2/dist/dist.min",-            "mapbox-gl": "https://api.mapbox.com/mapbox-gl-js/v3.0.1/mapbox-gl",-            # Without a path of its own, RequireJS' presence would send its-            # UMD down the AMD branch instead of assigning window.maplibregl.-            "maplibre-gl": f"{config.npm_cdn}/maplibre-gl/dist/maplibre-gl",-            "carto": f"{config.npm_cdn}/@deck.gl/carto@^{DECKGL_VERSION}/dist.min",-    },-        'exports': {-            "deck-gl": "deck", "mapbox-gl": "mapboxgl", "h3": "h3",-            "maplibre-gl": "maplibregl",-            # json and carto extend the deck namespace; the three loaders-            # bundles together make up window.loaders.-            "deck-json": "deck", "carto": "deck",-            "loader-csv": "loaders", "loader-json": "loaders",-            "loader-tiles": "loaders",-        },-        'shim': {-            # carto and the json catalogue extend deck.gl classes off-            # window.deck while their own factories run.-            'deck-json': {'deps': ["deck-gl"]},-            'carto': {'deps': ["deck-gl"]},-            'deck-gl': {'deps': ["h3"]}-        }-    }+    # No __js_require__: deck.gl's json and carto bundles embed UMD copies+    # of their dependencies and call define() anonymously more than once,+    # which RequireJS rejects. Loaded by script tag instead, they take their+    # browser-global branch even on a page with RequireJS.      data = Dict(String, Any) 
panel/models/deckgl.ts9 + / 2 −
@@ -58,6 +58,9 @@ export class DeckGLPlotView extends LayoutDOMView {     const error = await await_resources(this.model)     if (error != null) {       render_resource_error(this, error, LayoutDOMView.prototype.render)+    } else if (this.jsonConverter == null) {+      // Loaded on demand, deck.gl only arrives after initialize() ran.+      this._init_converter()     }   } @@ -97,7 +100,11 @@ export class DeckGLPlotView extends LayoutDOMView {     super.initialize()     const view_timeout = this.model.throttle.view || 200     this._view_cb = debounce((event: any) => this._on_viewState_event(event), view_timeout, false)-    if ((window as any).deck.JSONConverter) {+    this._init_converter()+  }++  _init_converter(): void {+    if ((window as any).deck?.JSONConverter) {       const {CSVLoader, Tiles3DLoader} = (window as any).loaders;       (window as any).loaders.registerLoaders([Tiles3DLoader, CSVLoader])       const jsonConverterConfiguration: any = {@@ -242,7 +249,7 @@ export class DeckGLPlotView extends LayoutDOMView {       this.jsonConverter.mergeConfiguration(configuration)       const props = this.jsonConverter.convert(jsonInput)       const getTooltip = makeTooltip(tooltip, props.layers)-      if (props.mapStyle === null) {+      if (props.mapStyle == null) {         props.map = null       } else if (props.mapStyle.includes("carto")) {         this._map = new (window as any).maplibregl.Map({
panel/models/resources.ts61 + / 0 −
@@ -127,6 +127,65 @@ function inject(el: HTMLScriptElement | HTMLLinkElement): Promise<void> {   }) } +/**+ * Hides RequireJS from the scripts the registry loads while they execute.+ *+ * On a page with RequireJS (the classic notebook, VS Code) a UMD bundle+ * finds `define.amd` and registers an anonymous AMD module instead of+ * assigning the global the components read, and RequireJS attributes that+ * anonymous define() to whichever script it loads next. Hiding `define`+ * itself for as long as the scripts load would also hide it from whatever+ * RequireJS loads in the meantime, so instead `define.amd` reads as+ * undefined only while Panel's own code is running: a classic script is+ * recognized by a marker attribute, a module script, which has no+ * `document.currentScript`, by the directory it was loaded from.+ */+const NO_AMD_ATTR = "data-panel-no-amd"++function install_amd_guard(): void {+  const global = globalThis as any+  const define = global.define+  if (typeof define !== "function" || define.amd == null || define.__panel_no_amd__ === true) {+    return+  }+  const dirs: string[] = global.__panel_no_amd_dirs__ ??= []+  const hidden = (): boolean => {+    const script = document.currentScript+    if (script != null) {+      return script.hasAttribute(NO_AMD_ATTR)+    }+    const stack = new Error().stack ?? ""+    return dirs.some((dir) => stack.includes(dir))+  }+  let amd = define.amd+  try {+    Object.defineProperty(define, "amd", {+      configurable: true,+      enumerable: true,+      get: () => hidden() ? undefined : amd,+      set: (value) => { amd = value },+    })+    Object.defineProperty(define, "__panel_no_amd__", {value: true})+  } catch (e) {+    console.warn(`Panel: could not hide RequireJS from component libraries: ${e}`)+  }+}++function hide_amd(el: HTMLScriptElement): void {+  el.setAttribute(NO_AMD_ATTR, "")+  install_amd_guard()+}++function hide_amd_module(url: string): void {+  const dirs: string[] = (globalThis as any).__panel_no_amd_dirs__ ??= []+  const dir = url_key(url).split("?")[0].replace(/[^/]*$/, "")+  // panel.js itself lives in a dist root, and must never match.+  if (!/\/(dist|static\/extensions\/panel)\/$/.test(dir) && !dirs.includes(dir)) {+    dirs.push(dir)+  }+  install_amd_guard()+}+ /**  * Injects a classic script tag.  *@@ -139,6 +198,7 @@ function inject_script(url: string): Promise<void> {   const el = document.createElement("script")   el.async = false   el.src = resource_url(url)+  hide_amd(el)   return inject(el) } @@ -162,6 +222,7 @@ function inject_module(url: string, name?: string): Promise<void> {   const el = document.createElement("script")   el.type = "module"   el.async = false+  hide_amd_module(url)   if (name == null) {     el.src = resource_url(url)     return inject(el)
panel/tests/io/test_notebook.py101 + / 4 −
@@ -9,12 +9,12 @@ from panel.config import config, panel_extension from panel.io import resources as resources_module from panel.io.notebook import (-    LOAD_MIME, ipywidget, load_notebook, replace_inline_css,-    require_components,+    LOAD_MIME, _autoload_js, _bundled_require_path, _cdn_url_key, ipywidget,+    load_notebook, replace_inline_css, require_components, ) from panel.io.resource_spec import _SPEC_CACHE, resource_spec from panel.io.resources import (-    CDN_DIST, CDN_ROOT, JS_VERSION, set_resource_mode,+    CDN_DIST, CDN_ROOT, JS_VERSION, Bundle, Resources, set_resource_mode, ) from panel.layout import Column from panel.models.echarts import ECharts@@ -181,6 +181,103 @@ def test_require_components_only_includes_active_panel_extensions(monkeypatch):     assert any('echarts' in config['paths'] for config in configs)  +def _tabulator_require(monkeypatch, mode, notebook):+    from bokeh.core.has_props import _default_resolver+    from bokeh.resources import CDN, INLINE++    monkeypatch.setattr(panel_extension, '_loaded_extensions', ['tabulator'])+    monkeypatch.setitem(+        _default_resolver._known_models, 'panel.models.tabulator.DataTabulator', DataTabulator+    )+    base = INLINE if mode == 'inline' else CDN+    with set_resource_mode(mode):+        resources = Resources.from_bokeh(base, notebook=notebook)+        return require_components(resources)+++@pytest.mark.parametrize('notebook', [True, False])+def test_require_components_load_bundled_copies(monkeypatch, notebook):+    """+    RequireJS must load Panel's bundled copies rather than the npm CDN,+    which a notebook on a restricted network cannot reach.++    See https://github.com/holoviz/panel/issues/8750.+    """+    configs, requirements, _, _, require_skip = _tabulator_require(monkeypatch, 'cdn', notebook)++    paths = next(config['paths'] for config in configs if 'tabulator' in config['paths'])+    bundled, original = paths['tabulator']+    dist = '/panel-preview/static/extensions/panel/' if notebook else CDN_DIST+    assert bundled == f'{dist}bundled/datatabulator/tabulator-tables@6.4.0/dist/js/tabulator.min'+    assert original == DataTabulator.__js_require__['paths']['tabulator']+    assert f'{bundled}.js' in require_skip+    assert 'tabulator' in requirements+    # The class-level declaration must not be rewritten in place.+    assert isinstance(DataTabulator.__js_require__['paths']['tabulator'], str)+++def test_require_components_skip_inlined_libraries(monkeypatch):+    """+    Inlined libraries assign their globals themselves, so RequireJS must not+    fetch them a second time from the network.+    """+    configs, requirements, exports, _, require_skip = _tabulator_require(monkeypatch, 'inline', True)++    assert not any('tabulator' in config['paths'] for config in configs)+    assert 'tabulator' not in requirements+    assert 'tabulator' not in exports+    assert require_skip == []+++@pytest.mark.parametrize(('path', 'raw', 'url', 'expected'), [+    # The same file+    (+        'https://cdn.jsdelivr.net/npm/echarts@6.0.0/dist/echarts.min',+        'https://cdn.jsdelivr.net/npm/echarts@6.0.0/dist/echarts.min.js',+        '/bundled/echarts/echarts@6.0.0/dist/echarts.min.js',+        '/bundled/echarts/echarts@6.0.0/dist/echarts.min',+    ),+    # A package resolved to its default file, bundled without `.js`+    (+        'https://cdn.jsdelivr.net/npm/vega@6.1.2/build/vega.min',+        'https://cdn.jsdelivr.net/npm/vega@6.1.2',+        '/bundled/vegaplot/vega@6.1.2',+        '/bundled/vegaplot/vega@6.1.2?',+    ),+    # A path naming the directory the bundled files live in+    (+        '//cdn.jsdelivr.net/npm/ace-builds@1.40.1/src-min-noconflict',+        'https://cdn.jsdelivr.net/npm/ace-builds@1.40.1/src-min-noconflict/ace.js',+        '/bundled/aceplot/ace-builds@1.40.1/src-min-noconflict/ace.js',+        '/bundled/aceplot/ace-builds@1.40.1/src-min-noconflict',+    ),+    # Unrelated files that only share a prefix+    (+        'https://cdn.jsdelivr.net/npm/@deck.gl/carto@^9.1.14/dist.min',+        'https://cdn.jsdelivr.net/npm/@loaders.gl/3d-tiles@4.2.2/dist/dist.min.js',+        '/bundled/deckglplot/@loaders.gl/3d-tiles@4.2.2/dist/dist.min.js',+        None,+    ),+], ids=['file', 'package', 'directory', 'unrelated'])+def test_bundled_require_path(path, raw, url, expected):+    assert _bundled_require_path(_cdn_url_key(path), _cdn_url_key(raw), url) == expected+++def test_autoload_js_preserves_inline_source():+    """+    Characters Python's splitlines() treats as line breaks (xterm.js has+    them in string literals) must reach the page untouched, otherwise the+    whole bootstrap is a syntax error.+    """+    source = 'var C0 = {FS: "\x1c", GS: "\x1d", RS: "\x1e", NEL: "\x85", LS: "\u2028"};'+    bundle = Bundle(js_raw=[source])+    bootstrap = _autoload_js(+        bundle=bundle, configs=[], requirements=[], exports={}, error_id='error',+        skip_imports={}, require_skip=[], ipywidget=False+    )+    assert source in bootstrap++ def test_notebook_endpoint_alert_stays_in_notebook_output(monkeypatch, notebook_bootstrap):     """     Endpoint failures must update Panel's output rather than the notebook UI.@@ -208,7 +305,7 @@ def publish_display_data(data, **kwargs):     assert 'element.src = resource_url(url)' in bootstrap     assert 'element.href = resource_url(url)' in bootstrap     assert 'new URL(resource_url(url), document.baseURI)' in bootstrap-    assert 'require_config.paths[name] = Array.isArray(path) ? path.map(resource_url) : resource_url(path)' in bootstrap+    assert 'require_config.paths[name] = (Array.isArray(path) ? path : [path]).flatMap(require_urls)' in bootstrap   def test_notebook_vscode_resources_use_cdn(monkeypatch, notebook_bootstrap):
panel/tests/ui/io/test_nbclassic.py72 + / 30 −
@@ -1,13 +1,18 @@ import json+import pathlib import time +from urllib.parse import unquote+ import pytest import requests  pytest.importorskip("playwright")  from playwright.sync_api import TimeoutError, expect +from panel.io.resources import CDN_DIST, DIST_DIR+ pytestmark = [pytest.mark.ui, pytest.mark.jupyter, pytest.mark.flaky(reruns=3)]  @@ -131,6 +136,47 @@ def run_notebook(page, nbclassic_server, notebook_name, cells):     assert not errors, '\n'.join(errors)  +#: Hosts the bundled libraries originally come from. Panel must not need+#: them, so a notebook on a network that blocks them still renders.+PUBLIC_CDNS = ('cdn.jsdelivr.net', 'unpkg.com', 'cdn.plot.ly', 'api.mapbox.com')+++def _serve_local_dist(page):+    """+    Serves Panel's CDN urls from the local build, so modes that resolve+    resources against the CDN (comms='ipywidgets', like VS Code) exercise+    this checkout rather than the last release.+    """+    dist = pathlib.Path(DIST_DIR)++    def fulfill(route):+        path = dist / unquote(route.request.url.split('?')[0].removeprefix(CDN_DIST))+        if path.is_file():+            route.fulfill(path=str(path))+        else:+            route.fallback()++    page.route(f'{CDN_DIST}**', fulfill)+++def _enable_jupyter_bokeh(page):+    """+    Maps the widget module the way jupyter_bokeh's nbextension does, which+    is not necessarily enabled where the tests run.+    """+    page.add_init_script("""+    const timer = setInterval(() => {+      if (window.requirejs == null) {+        return+      }+      clearInterval(timer)+      window.requirejs.config({map: {'*': {'@bokeh/jupyter_bokeh': 'nbextensions/jupyter_bokeh/index'}}})+    }, 1)+    """)+++@pytest.mark.parametrize('comms', ['default', 'ipywidgets'])+@pytest.mark.parametrize('mode', ['eager', 'inline', 'lazy']) @pytest.mark.parametrize(     ('extension', 'component', 'selector'),     [@@ -141,53 +187,49 @@ def run_notebook(page, nbclassic_server, notebook_name, cells):         ('jsoneditor', "pn.widgets.JSONEditor(value={'answer': 42}, height=160)", '.jsoneditor'),         ('echarts', "pn.pane.ECharts({'xAxis': {}, 'yAxis': {}, 'series': []}, height=180)", '[_echarts_instance_]'),         ('vega', "pn.pane.Vega({'$schema': 'https://vega.github.io/schema/vega-lite/v5.json', 'mark': 'bar', 'data': {'values': []}}, height=180)", '.vega-embed'),-        pytest.param(-            'deckgl',-            "pn.pane.DeckGL({'initialViewState': {'longitude': 0, 'latitude': 0, 'zoom': 1}, 'layers': []}, height=180)",-            '.deckgl canvas',-            marks=pytest.mark.xfail(-                reason=(-                    "deck.gl's json and carto bundles each call define() more than "-                    "once anonymously, because they embed UMD copies of their own "-                    "dependencies (long.js). RequireJS can attribute only one "-                    "anonymous define per script and rejects the rest with "-                    "'Mismatched anonymous define()', so window.deck never gains "-                    "JSONConverter. No paths/shim/exports configuration can fix a "-                    "script that defines more than one anonymous module; loading "-                    "them outside RequireJS is the only remedy."-                ),-                strict=True,-            ),-        ),+        ('deckgl', "pn.pane.DeckGL({'initialViewState': {'longitude': 0, 'latitude': 0, 'zoom': 1}, 'layers': []}, height=180)", '.deckgl canvas'),         ('terminal', 'pn.widgets.Terminal(height=100)', '.xterm'),         ('katex', "pn.pane.LaTeX(r'$E = mc^2$')", '.katex'),+        ('plotly', "pn.pane.Plotly({'data': [{'type': 'bar', 'y': [1, 2, 3]}], 'layout': {}}, height=200)", '.js-plotly-plot'),     ],     ids=[         'tabulator', 'perspective', 'filedropper', 'codeeditor', 'jsoneditor',-        'echarts', 'vega', 'deckgl', 'terminal', 'katex',+        'echarts', 'vega', 'deckgl', 'terminal', 'katex', 'plotly',     ], )-def test_nbclassic_component_resources(page, nbclassic_server, extension, component, selector):-    """Load each bundled component independently through nbclassic resources."""+def test_nbclassic_component_resources(page, nbclassic_server, extension, component, selector, mode, comms):+    """+    Load each bundled component independently through nbclassic resources,+    declared up front (eager), inlined, or only requested on render (lazy).++    comms='ipywidgets' renders through jupyter_bokeh's widget, the way VS+    Code renders Panel output.++    See https://github.com/holoviz/panel/issues/8750.+    """+    if comms == 'ipywidgets':+        pytest.importorskip('jupyter_bokeh')+        _enable_jupyter_bokeh(page)+    _serve_local_dist(page)+    for host in PUBLIC_CDNS:+        page.route(f'**://{host}/**', lambda route: route.abort())     console_errors = []     page.on('console', lambda message: console_errors.append(message.text) if message.type == 'error' else None)     page.on('pageerror', lambda error: console_errors.append(str(error)))-    run_notebook(page, nbclassic_server, extension, [+    if mode == 'lazy':+        load = f"pn.extension(comms='{comms}', inline=False)"+    else:+        load = f"pn.extension('{extension}', comms='{comms}', inline={mode == 'inline'})"+    run_notebook(page, nbclassic_server, f'{extension}_{mode}_{comms}', [         'import pandas as pd\nimport panel as pn',-        f"pn.extension('{extension}', comms='default', inline=False)",+        load,         component,     ])      try:         expect(page.locator(selector)).to_have_count(1, timeout=10000)     except AssertionError as error:-        globals_ = page.evaluate("""() => ({-            vega: typeof window.vega,-            vegaLiteCompile: typeof window.vegaLite?.compile,-            vlCompile: typeof window.vl?.compile,-            vegaEmbed: typeof window.vegaEmbed,-        })""")-        raise AssertionError(f'{error}\nVega globals: {globals_}\nConsole errors:\n' + '\n'.join(console_errors)) from error+        raise AssertionError(f'{error}\nConsole errors:\n' + '\n'.join(console_errors)) from error   def test_nbclassic_component_comm(page, nbclassic_server):