holoviz/panel · #8762

fix: Fix notebook resource loading with RequireJS and add a Jupyter extension endpoint

philippjfr · merged Sep 15, 202618 files · 1050 + / 76
panel/_templates/autoload_panel_js.js186 + / 38
@@ -26,6 +26,12 @@ calls it with the rendered model.   const Bokeh = root.Bokeh;   const BK_RE = /^https:\/\/cdn\.bokeh\.org\/bokeh\/(release|dev)\/bokeh-/;   const PN_RE = /^https:\/\/cdn\.holoviz\.org\/panel\/[^/]+\/dist\/panel/i;+  const JUPYTER_EXTENSION_PATH = "/panel-preview/static/extensions/panel/";+  const CDN_DIST = {{ cdn_dist|json }};+  // Exposed so the lazy-resource registry (models/resources.ts) can fall+  // back to the CDN for the same endpoint, not just the eager bootstrap+  // resources loaded below.+  root.__panel_cdn_dist__ = CDN_DIST;    // 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)) {@@ -45,6 +51,31 @@ calls it with the rendered model.     console.debug("Bokeh: all callbacks have finished");   } +  function show_jupyter_extension_error() {+    const element = document.getElementById("{{ error_id }}");+    if (element == null || !element.hidden) {+      return;+    }+    element.style.cssText = "color: #b91c1c; font-family: sans-serif; padding: 0.5em;";+    element.textContent = (+      "Panel could not load resources from its Jupyter server extension. " ++      "Install Panel in the environment running the Jupyter server and restart it."+    );+    element.hidden = false;+  }+  root.__panel_jupyter_extension_error__ = show_jupyter_extension_error;++  {% if check_extension %}+  // Proactively checks the extension is actually installed, rather than+  // waiting for some resource's load to fail. A plain HEAD request for the+  // bundle every notebook session needs is enough to tell.+  fetch(JUPYTER_EXTENSION_PATH + "panel.min.js", {method: "HEAD"}).then((response) => {+    if (!response.ok) {+      show_jupyter_extension_error();+    }+  }).catch(() => show_jupyter_extension_error());+  {% endif %}+   function load_libs(css_urls, js_urls, js_modules, Bokeh, callback) {     if (css_urls == null) css_urls = [];     if (js_urls == null) js_urls = [];@@ -71,25 +102,99 @@ calls it with the rendered model.     }     window._bokeh_on_load = on_load -    function on_error(e) {-      const src_el = e.srcElement-      console.error("failed to load " + (src_el.href || src_el.src));+    function on_error(url) {+      console.error("failed to load " + url);+      if (url.includes(JUPYTER_EXTENSION_PATH)) {+        show_jupyter_extension_error();+      }+    }++    function fallback_to_cdn(element, url, attribute, parent) {+      const index = url.indexOf(JUPYTER_EXTENSION_PATH);+      if (index === -1 || element.dataset.panelCdnFallback != null) {+        return false;+      }+      element.dataset.panelCdnFallback = "";+      element.remove();+      element[attribute] = CDN_DIST + url.slice(index + JUPYTER_EXTENSION_PATH.length);+      parent.appendChild(element);+      return true;+    }++    function inject_script_tag(url) {+      const element = document.createElement('script');+      element.onload = on_load;+      element.onerror = () => {+        if (!fallback_to_cdn(element, url, "src", document.head)) {+          on_error(url);+        }+      };+      element.async = false;+      element.src = url;+      console.debug("Bokeh: injecting script tag for BokehJS library: ", url);+      document.head.appendChild(element);     }      const skip = [];+    // Held open until every resource below has been queued, then released+    // by on_load() at the end of this function.+    root._bokeh_is_loading = 1;     if (window.requirejs) {       window.requirejs.config({{ config|conffilter }});+      {% if requirements %}+      // Assigns each library's global as its own module resolves, since a+      // library whose factory reads another's global (e.g. deck.gl's carto+      // layers reading window.deck) runs during the batch, not after it.       {% for r in requirements %}-      require(["{{ r }}"], function({{ exports[r] }}) {-        {% if r in exports %}-        window.{{ exports[r] }} = {{ exports[r] }}-        {% endif %}-        on_load()+      {% if r in exports %}+      define("{{ r }}{{ global_suffix }}", ["{{ r }}"], function(module) {+        const name = "{{ exports[r] }}"+        const existing = window[name]+        // 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.+        if (existing != null && typeof existing === "object" &&+            module != null && typeof module === "object") {+          window[name] = Object.assign({}, existing, module)+        } else {+          window[name] = module+        }+        return window[name]       })+      {% endif %}       {% endfor %}-      root._bokeh_is_loading = css_urls.length + {{ requirements|length }};-    } else {-      root._bokeh_is_loading = css_urls.length + js_urls.length + js_modules.length;+      root._bokeh_is_loading++;+      // Required in stages so a library reading another's global finds it+      // assigned, and so one failing library doesn't stop the rest.+      const require_stages = {{ require_stages|default([])|json }};+      const assign_resolved = () => {+        {% for r in requirements %}+        {% if r in exports %}+        if (window.requirejs.defined("{{ r }}{{ global_suffix }}")) {+          require("{{ r }}{{ global_suffix }}")+        }+        {% endif %}+        {% endfor %}+      };+      const require_stage = (index) => {+        if (index >= require_stages.length) {+          // Only now are the globals the components read actually assigned.+          require_ready_resolve()+          on_load()+          return+        }+        require(require_stages[index], () => require_stage(index + 1), (error) => {+          const modules = error.requireModules ? error.requireModules.join(', ') : 'unknown';+          console.error(`Panel: requirejs failed to load ${modules}: ${error.requireType} ${error.message}`);+          // Publish whatever this stage did resolve, so that one library+          // failing does not blank every component on the page.+          assign_resolved()+          on_error(modules)+          require_stage(index + 1)+        })+      };+      require_stage(0)+      {% endif %}     }      const existing_stylesheets = []@@ -104,12 +209,16 @@ calls it with the rendered model.       const url = css_urls[i];       const escaped = encodeURI(url)       if (existing_stylesheets.indexOf(escaped) !== -1) {-        on_load()         continue;       }       const element = document.createElement("link");+      root._bokeh_is_loading++;       element.onload = on_load;-      element.onerror = on_error;+      element.onerror = () => {+        if (!fallback_to_cdn(element, url, "href", document.body)) {+          on_error(url);+        }+      };       element.rel = "stylesheet";       element.type = "text/css";       element.href = url;@@ -118,13 +227,24 @@ calls it with the rendered model.     }      {%- for lib, urls in skip_imports.items() %}-    if (((window.{{ lib }} !== undefined) && (!(window.{{ lib }} instanceof HTMLElement))) || window.requirejs) {+    // The global is already there, so re-fetching what provides it is waste.+    if ((window.{{ lib }} !== undefined) && (!(window.{{ lib }} instanceof HTMLElement))) {       var urls = {{ urls }};       for (var i = 0; i < urls.length; i++) {         skip.push(encodeURI(urls[i]))       }     }     {%- endfor %}+    {%- if require_skip %}+    // RequireJS already has its own path for these, so a script tag would+    // load them a second time and corrupt RequireJS' module resolution.+    if (window.requirejs) {+      var urls = {{ require_skip|json }};+      for (var i = 0; i < urls.length; i++) {+        skip.push(encodeURI(urls[i]))+      }+    }+    {%- endif %}     var existing_scripts = []     const scripts = document.getElementsByTagName('script')     for (let i = 0; i < scripts.length; i++) {@@ -140,31 +260,25 @@ calls it with the rendered model.       const isBokehOrPanel = BK_RE.test(escaped) || PN_RE.test(escaped)       const missingOrBroken = Bokeh == null || Bokeh.Panel == null || (Bokeh.version != version && !Bokeh.versions?.has(version)) || Bokeh.versions?.get(version)?.Panel == null;       if (shouldSkip && !(isBokehOrPanel && missingOrBroken)) {-        if (!window.requirejs) {-          on_load();-        }         continue;       }-      const element = document.createElement('script');-      element.onload = on_load;-      element.onerror = on_error;-      element.async = false;-      element.src = url;-      console.debug("Bokeh: injecting script tag for BokehJS library: ", url);-      document.head.appendChild(element);+      root._bokeh_is_loading++;+      inject_script_tag(url);     }     for (let i = 0; i < js_modules.length; i++) {       const [url, name] = js_modules[i];       const escaped = encodeURI(url)       const loaded = name == null ? existing_scripts.indexOf(escaped) !== -1 : root[name] != null       if (skip.indexOf(escaped) !== -1 || loaded) {-        if (!window.requirejs) {-          on_load();-        }         continue;       }       var element = document.createElement('script');-      element.onerror = on_error;+      root._bokeh_is_loading++;+      element.onerror = () => {+        if (!fallback_to_cdn(element, url, "src", document.head)) {+          on_error(url);+        }+      };       element.async = false;       element.type = "module";       if (name == null) {@@ -187,9 +301,9 @@ calls it with the rendered model.       console.debug("Bokeh: injecting script tag for BokehJS library: ", url);       document.head.appendChild(element);     }-    if (!js_urls.length && !js_modules.length) {-      on_load()-    }+    // Releases the reservation taken above, running the callbacks now if+    // nothing else is outstanding.+    on_load()   };    function inject_raw_css(css) {@@ -208,13 +322,25 @@ calls it with the rendered model.     },     {%- endfor %}     {%- for js in (bundle.js_raw if bundle else js_raw) %}-    function(Bokeh) {+    function(Bokeh, define, module, exports) {       {{ js|indent(6) }}     },     {% endfor -%}-    function(Bokeh) {} // ensure no trailing comma for IE+    function(Bokeh, define, module, exports) {} // ensure no trailing comma for IE   ]; +  // Resolved once RequireJS has loaded its libraries and assigned their+  // globals, so the resource registry can wait for them instead of racing.+  let require_ready_resolve;+  const require_ready = new Promise((resolve) => { require_ready_resolve = resolve });+  {%- if requirements %}+  // A claimed library that is never released would keep its components from+  // ever rendering, so the wait is bounded by the same timeout as the load.+  setTimeout(() => require_ready_resolve(), {{ timeout|default(0)|json }} || 5000);+  {%- else %}+  require_ready_resolve();+  {%- endif %}+   function declare_resources() {     // Tells the panel.js resource registry which component libraries this     // bundle has already satisfied, so nothing is fetched a second time.@@ -224,16 +350,34 @@ calls it with the rendered model.     if (!declared || !(declared.libs || declared.css)) {       return;     }-    if (root.__panel_resources__ != null) {-      root.__panel_resources__.declare(declared);-    } else {-      (root.__panel_resources_declared__ = root.__panel_resources_declared__ || []).push(declared);+    // Libraries RequireJS is loading are claimed against require_ready+    // instead of being declared, since they are not ready yet.+    const require_urls = window.requirejs ? {{ require_skip|default([])|json }} : [];+    const satisfied = [], pending = [];+    for (const lib of declared.libs || []) {+      const urls = lib.js || [];+      const by_require = urls.length > 0 && urls.every((url) => require_urls.includes(url));+      (by_require ? pending : satisfied).push(lib);+    }+    const declarations = [{libs: satisfied, css: declared.css}];+    if (pending.length > 0) {+      declarations.push({libs: pending, ready: require_ready});+    }+    for (const declaration of declarations) {+      if (root.__panel_resources__ != null) {+        if (declaration.ready != null) {+          root.__panel_resources__.claim(declaration, declaration.ready);+        } else {+          root.__panel_resources__.declare(declaration);+        }+      } else {+        (root.__panel_resources_declared__ = root.__panel_resources_declared__ || []).push(declaration);+      }     }   }    function run_inline_js() {     if ((root.Bokeh !== undefined) || (force === true)) {-      declare_resources();       for (let i = 0; i < inline_js.length; i++) {         try {           inline_js[i].call(root, root.Bokeh);@@ -298,6 +442,10 @@ calls it with the rendered model.       });     }   }+  // Declared synchronously, before anything is scheduled, so a cell whose+  // output embeds while libraries are still loading finds this in place+  // instead of fetching its own second copy of each.+  declare_resources();   // Give older versions of the autoload script a head-start to ensure   // they initialize before we start loading newer version.   setTimeout(load_or_wait, 100)
panel/io/notebook.py167 + / 16
@@ -6,6 +6,7 @@  import json import os+import re import sys import typing as t import uuid@@ -119,23 +120,74 @@ def push_on_root(ref: str): AUTOLOAD_NB_JS: Template = _env.get_template("autoload_panel_js.js") NB_TEMPLATE_BASE: Template = _env.get_template('nb_template.html') +def _require_stages(requirements, shim):+    """+    Groups requirements into stages so each loads after its dependencies.++    RequireJS ignores ``shim`` deps for scripts that call ``define``+    themselves (deck.gl's carto and json bundles do), so those are ordered+    via stages instead, which also gives a stage's globals time to be+    assigned before the next one runs.+    """+    pending = {+        name: {+            dep for dep in (shim.get(name, {}).get('deps') or [])+            if dep in requirements and dep != name+        }+        for name in requirements+    }+    stages, resolved = [], set()+    while pending:+        stage = [name for name, deps in pending.items() if deps <= resolved]+        if not stage:+            # A dependency cycle, so give up on ordering the remainder.+            stage = list(pending)+        stages.append(stage)+        resolved.update(stage)+        for name in stage:+            del pending[name]+    return stages+++#: Suffix of the helper modules that assign a library's browser global as+#: soon as its own module resolves.+GLOBAL_MODULE_SUFFIX = '__panel_global'++ def _autoload_js(-    *, bundle, configs, requirements, exports, skip_imports, ipywidget,-    reloading=False, load_timeout=5000+    *, bundle, configs, requirements, exports, error_id, skip_imports, require_skip,+    check_extension=False, ipywidget, reloading=False, load_timeout=5000 ):     config = {'packages': {}, 'paths': {}, 'shim': {}}     for conf in configs:         for key, c in conf.items():             config[key].update(c)+    stages = _require_stages(requirements, config['shim'])+    # A deps-only shim entry is now redundant (the stages above order it+    # instead), and leaving it in makes RequireJS treat a module that calls+    # define() itself as shimmed, resolving it to undefined.+    config['shim'] = {+        name: shim for name, shim in config['shim'].items() if shim.get('exports')+    }+    stages = [+        [f'{r}{GLOBAL_MODULE_SUFFIX}' if r in exports else r for r in stage]+        for stage in stages+    ]     return AUTOLOAD_NB_JS.render(         bundle    = bundle,         force     = not reloading,         reloading = reloading,         timeout   = load_timeout,+        cdn_dist  = CDN_DIST,         config    = config,         requirements = requirements,+        require_stages = stages,         exports   = exports,+        global_suffix = GLOBAL_MODULE_SUFFIX,+        error_id  = error_id,         skip_imports = skip_imports,+        require_skip = require_skip,+        check_extension = check_extension,         ipywidget = ipywidget,         version = bokeh.__version__     )@@ -281,7 +333,81 @@ def mimebundle_to_html(bundle: dict[str, t.Any]) -> str:     return html  -def require_components():+def _cdn_url_key(url):+    """+    Normalizes a cdn url so a RequireJS path and a resource url for the+    same file compare equal, ignoring the ``.js`` suffix and protocol.+    """+    url = url.split('?')[0].split('#')[0]+    url = re.sub(r'^(?:[a-z][a-z0-9+.-]*:)?//', '', url)+    if '/npm/' in url:+        url = url.split('/npm/', 1)[1]+    elif '/' in url:+        # Drop the host, keeping the path for cdns without an /npm/ prefix.+        url = url.split('/', 1)[1]+    return url[:-3] if url.endswith('.js') else url+++def _require_covered_urls(model, model_require, resources=None):+    """+    Resolved urls of a model's scripts that RequireJS is going to load.++    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.+    """+    paths = model_require.get('paths', {}) or {}+    path_keys = set()+    for value in paths.values():+        for url in (value if isinstance(value, (list, tuple)) else (value,)):+            if isinstance(url, str):+                path_keys.add(_cdn_url_key(url))+    if not path_keys:+        return []++    try:+        raw = list(getattr(model, '__javascript_raw__', None) or [])+        declared = list(getattr(model, '__javascript__', None) or [])+    except Exception:+        return []+    if len(raw) != len(declared):+        return []++    resolved = resources.adjust_paths(declared) if resources is not None else declared+    covered = []+    for raw_url, url in zip(raw, 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+        ):+            covered.append(url)+    return covered+++def _resolve_js_skip(skip, resources=None):+    """+    Resolves ``__js_skip__`` urls into the form the bundle emits, so they+    can be compared against it. Without this, a component whose global is+    already present would still get a second, duplicate script tag.+    """+    resolved = {}+    for name, urls in (skip or {}).items():+        if isinstance(urls, str):+            urls = [urls]+        elif not isinstance(urls, (list, tuple)):+            continue+        urls = [url for url in urls if isinstance(url, str)]+        resolved[name] = resources.adjust_paths(urls) if resources is not None else urls+    return resolved+++def require_components(resources=None):     """     Returns JS snippet to load the required dependencies in the classic     notebook using REQUIRE JS.@@ -290,17 +416,28 @@ def require_components():     no effect outside the classic notebook. Components should declare     ``__javascript__``/``__javascript_modules__``/``__css__`` instead and     let panel.io.resource_spec derive the rest.++    ``resources``, if given, resolves the ``__js_skip__`` urls so they+    match what the bundle emits.     """-    from ..config import config+    from ..config import config, panel_extension      configs, requirements, exports = [], [], {}     js_requires = []+    active_modules = tuple(+        panel_extension._imports[extension]+        for extension in panel_extension._loaded_extensions+        if extension in panel_extension._imports+    )      for qual_name, model in Model.model_class_reverse_map.items():-        # We need to enable Models from Panel as well as Panel extensions-        # like awesome_panel_extensions.-        # The Bokeh models do not have "." in the qual_name-        if "." in qual_name:+        # Third-party models have no Panel extension metadata, so retain their+        # requirements. Panel models only contribute when their extension was+        # explicitly activated in this notebook.+        module = model.__module__+        if "." in qual_name and (+            not module.startswith('panel.') or module.startswith(active_modules)+        ):             js_requires.append(model)      from ..reactive import ReactiveHTML@@ -312,12 +449,13 @@ def require_components():         js_requires.append(conf)      skip_import = {}+    require_skip = []     for model in js_requires:         if not isinstance(model, dict) and issubclass(model, ReactiveHTML) and not model._loaded():             continue          if hasattr(model, '__js_skip__'):-            skip_import.update(model.__js_skip__)+            skip_import.update(_resolve_js_skip(model.__js_skip__, resources))          if not (hasattr(model, '__js_require__') or isinstance(model, dict)):             continue@@ -326,6 +464,9 @@ def require_components():             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)          model_exports = model_require.pop('exports', {})         if not any(model_require == config for config in configs):@@ -343,7 +484,7 @@ def require_components():                     if r in model_exports:                         exports[r] = model_exports[r] -    return configs, requirements, exports, skip_import+    return configs, requirements, exports, skip_import, require_skip   class JupyterCommJSBinary(JupyterCommJS):@@ -426,37 +567,47 @@ def load_notebook( ) -> None:     from IPython.display import publish_display_data +    from ..config import config+     resources = INLINE if inline and not state._is_pyodide else CDN-    nb_endpoint = not state._is_pyodide+    # The Jupyter extension endpoint only exists for a plain notebook kernel:+    # 'vscode'/'colab'/'ipywidgets' render through a different mimebundle+    # path (see Renderable._repr_mimebundle_) and never hit this endpoint.+    nb_endpoint = not state._is_pyodide and config.comms == 'default'      # Components rendered in a later cell resolve their resources outside     # any set_resource_mode block, so the notebook mode has to become the-    # default rather than being scoped to the bootstrap. Inline output has-    # no urls to hand out after the fact, hence the CDN.-    set_default_resource_mode('cdn' if resources.mode == 'inline' else resources.mode)+    # default rather than being scoped to the bootstrap. Panel resources use+    # the Jupyter extension endpoint; Bokeh resources keep their CDN urls.+    set_default_resource_mode('cdn' if resources.mode == 'inline' else resources.mode, notebook=nb_endpoint)      with set_resource_mode(resources.mode):         resources = Resources.from_bokeh(resources, notebook=nb_endpoint)         bundle = bundle_resources(             None, resources, notebook=nb_endpoint, reloading=reloading,             enable_mathjax=enable_mathjax         )-        configs, requirements, exports, skip_imports = require_components()+        configs, requirements, exports, skip_imports, require_skip = require_components(resources)         ipywidget = 'ipywidgets_bokeh' in sys.modules+        error_id = make_id()         bokeh_js = _autoload_js(             bundle=bundle,             configs=configs,             requirements=requirements,             exports=exports,+            error_id=error_id,             skip_imports=skip_imports,+            require_skip=require_skip,+            check_extension=nb_endpoint,             ipywidget=ipywidget,             reloading=reloading,             load_timeout=load_timeout         )      CSS = (PANEL_DIR / '_templates' / 'jupyter.css').read_text(encoding='utf-8')     shim = '<script type="esms-options">{"shimMode": true}</script>'-    publish_display_data(data={'text/html': f'{shim}<style>{CSS}</style>'})+    error = f'<div id="{error_id}" role="alert" hidden></div>'+    publish_display_data(data={'text/html': f'{shim}<style>{CSS}</style>{error}'})     publish_display_data({         'application/javascript': bokeh_js,         LOAD_MIME: bokeh_js,
panel/io/resource_spec.py14 + / 3
@@ -30,8 +30,9 @@ from ..config import config from ..util import isurl from .resources import (-    Resources, component_resource_path, extension_declared, get_resource_mode,-    resolve_resource_cdn, set_resource_mode,+    Resources, component_resource_path, extension_declared,+    get_notebook_resources, get_resource_mode, resolve_resource_cdn,+    set_resource_mode, ) from .state import state @@ -82,7 +83,13 @@ def _spec_mode(mode: MODES | None = None) -> tuple[str, bool]:   def _resources(mode: str) -> Resources:-    return Resources(mode=mode)+    # `NOTEBOOK_RESOURCES` is a process-wide default set by the notebook's+    # own bootstrap, so it isn't scoped to that notebook's document. A+    # server started from the same kernel process (e.g. `pn.serve(...,+    # threaded=True)`) must not inherit it, or every lazily-loaded resource+    # would 404 against an endpoint that server never registers.+    notebook = get_notebook_resources() and not state._is_server_session+    return Resources(mode=mode, notebook=notebook)   def _parse_probe(expression: str) -> dict[str, str] | None:@@ -332,8 +339,12 @@ def resource_spec(cls: type, mode: MODES | None = None) -> dict[str, t.Any] | No     if not config.lazy_resources or not _has_resources(cls):         return None     resolved_mode, inline_fallback = _spec_mode(mode)+    # The effective notebook default has to be part of the key too: a+    # notebook and a server started from it can share `rel_path`/`base_url`+    # while only one of them is a genuine server session (see `_resources`).     key = (         cls, resolved_mode, state.rel_path, state.base_url,+        get_notebook_resources() and not state._is_server_session,         tuple(getattr(cls, '__css_raw__', None) or ()),     )     if key in _SPEC_CACHE:
panel/io/resources.py22 + / 2
@@ -98,6 +98,7 @@ def parse_template(*args, **kwargs):  # Handle serving of the panel extension before session is loaded RESOURCE_MODE: MODES = 'server'+NOTEBOOK_RESOURCES = False PANEL_DIR = Path(__file__).parent.parent DIST_DIR = PANEL_DIR / 'dist' BUNDLE_DIR = DIST_DIR / 'bundled'@@ -200,7 +201,11 @@ def get_resource_mode() -> MODES:     """     return RESOURCE_MODE -def set_default_resource_mode(mode: MODES):+def get_notebook_resources() -> bool:+    """Whether resource urls should use the Jupyter extension endpoint."""+    return NOTEBOOK_RESOURCES++def set_default_resource_mode(mode: MODES, *, notebook: bool = False):     """     Sets the mode urls are resolved for outside a set_resource_mode block. @@ -212,8 +217,9 @@ def set_default_resource_mode(mode: MODES):     context manager either, because components created in a later cell     resolve their resources long after ``pn.extension()`` returned.     """-    global RESOURCE_MODE+    global NOTEBOOK_RESOURCES, RESOURCE_MODE     RESOURCE_MODE = mode+    NOTEBOOK_RESOURCES = notebook  def use_cdn() -> bool:     return _settings.resources(default="server") != 'server' or state._is_pyodide@@ -844,6 +850,20 @@ def adjust_paths(self, resources):                     resource = f'{self.root_url}{resource}'             if resource.endswith('.css') and not resource.startswith(('http:', 'https:')):                 resource += version_suffix+            if self.notebook:+                # The render endpoint sets `rel_path` to its own+                # `panel-preview` root already, so building on `base_url`+                # there would double it. nbclassic never sets `rel_path`,+                # so `base_url` is the raw server root there instead.+                if state.rel_path and state.rel_path.rstrip('/').endswith('panel-preview'):+                    endpoint = f"{state.rel_path.rstrip('/')}/static/extensions/panel/"+                else:+                    base_url = state.base_url.removesuffix('nbclassic/')+                    endpoint = f'{base_url}panel-preview/static/extensions/panel/'+                if resource.startswith(CDN_DIST):+                    resource = endpoint + resource.removeprefix(CDN_DIST)+                elif resource.startswith(LOCAL_DIST):+                    resource = endpoint + resource.removeprefix(LOCAL_DIST)             new_resources.append(resource)         return new_resources 
panel/io/state.py14 + / 0
@@ -374,6 +374,20 @@ def _is_launching(self) -> bool:             return False         return not bool(curdoc.session_context.server_context.sessions) +    @property+    def _is_server_session(self) -> bool:+        """+        Whether the current document belongs to a genuine served session,+        as opposed to a notebook comm-rendered document or the Jupyter+        extension's own render endpoint (both leave ``server_context``+        unset). Used to keep notebook-only resource defaults from leaking+        into a server started from within that same notebook process.+        """+        curdoc = self.curdoc+        return bool(+            curdoc and curdoc.session_context and curdoc.session_context.server_context+        )+     @property     def _is_pyodide(self) -> bool:         return '_pyodide' in sys.modules
panel/models/deckgl.py15 + / 1
@@ -70,11 +70,25 @@ def __js_skip__(cls):             "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"},+        '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"]}         }     }
panel/models/deckgl.ts1 + / 1
@@ -353,7 +353,7 @@ export class DeckGLPlot extends LayoutDOM {     this.prototype.default_view = DeckGLPlotView      this.define<DeckGLPlot.Props>(({Any, List, Str, Ref}) => ({-      data:             [ Any                              ],+      data:             [ Any,                          {} ],       data_sources:     [ List(Ref(ColumnDataSource)), [] ],       clickState:       [ Any,                          {} ],       hoverState:       [ Any,                          {} ],
panel/models/echarts.py3 + / 1
@@ -52,7 +52,9 @@ def __js_skip__(cls):             "echarts":  f"{config.npm_cdn}/echarts@{ECHARTS_VERSION}/dist/echarts.min",             "echarts-gl": f"{config.npm_cdn}/echarts-gl@2.0.9/dist/echarts-gl.min"         },-        'exports': {}+        # echarts-gl registers itself with echarts and exposes nothing the view+        # reads, so only echarts needs a global.+        'exports': {'echarts': 'echarts'}     }      data = Nullable(Dict(String, Any))
panel/models/resources.ts73 + / 5
@@ -70,12 +70,51 @@ function existing_urls(selector: string, attr: "src" | "href"): Set<string> {   return urls } +const JUPYTER_EXTENSION_PATH = "/panel-preview/static/extensions/panel/"++/**+ * Rewrites a failed Jupyter-extension-endpoint url to its CDN equivalent,+ * mirroring the eager bootstrap's fallback in autoload_panel_js.js. Only+ * tried once per element, and only when the bootstrap has published the+ * CDN base (it hasn't in server/served-app contexts, where this endpoint+ * never appears in the first place).+ */+function fallback_to_cdn(el: HTMLScriptElement | HTMLLinkElement, url: string): string | null {+  const index = url.indexOf(JUPYTER_EXTENSION_PATH)+  const cdn_dist = (globalThis as any).__panel_cdn_dist__+  if (index === -1 || typeof cdn_dist !== "string" || el.dataset.panelCdnFallback != null) {+    return null+  }+  el.dataset.panelCdnFallback = ""+  return cdn_dist + url.slice(index + JUPYTER_EXTENSION_PATH.length)+}+ function inject(el: HTMLScriptElement | HTMLLinkElement): Promise<void> {   return new Promise<void>((resolve, reject) => {-    el.addEventListener("load", () => resolve(), {once: true})-    el.addEventListener("error", () => reject(-      new Error(`Failed to load ${(el as HTMLScriptElement).src || (el as HTMLLinkElement).href}`),-    ), {once: true})+    const attempt = (element: HTMLScriptElement | HTMLLinkElement) => {+      element.addEventListener("load", () => resolve(), {once: true})+      element.addEventListener("error", () => {+        const url = (element as HTMLScriptElement).src || (element as HTMLLinkElement).href+        const fallback = fallback_to_cdn(element, url)+        if (fallback != null) {+          element.remove()+          if (element instanceof HTMLLinkElement) {+            element.href = fallback+          } else {+            element.src = fallback+          }+          attempt(element)+          document.head.appendChild(element)+          return+        }+        if (url.includes(JUPYTER_EXTENSION_PATH)) {+          const global = globalThis as any+          global.__panel_jupyter_extension_error__?.()+        }+        reject(new Error(`Failed to load ${url}`))+      }, {once: true})+    }+    attempt(el)     document.head.appendChild(el)   }) }@@ -210,6 +249,28 @@ export class ResourceRegistry {     }   } +  /**+   * Records that a loader outside the registry (RequireJS, in the classic+   * notebook) is already fetching these libraries. Unlike `declare`, this+   * does not mark them ready immediately: `await_resources` waits on+   * `ready` instead, so a view doesn't read an unassigned global.+   */+  claim(declared: {libs?: LibSpec[]}, ready: Promise<void>): void {+    for (const lib of declared.libs ?? []) {+      if (lib == null || lib.name == null) {+        continue+      }+      this.specs.set(lib.name, lib)+      this.libs.set(lib.name, ready)+      for (const url of lib.js ?? []) {+        this.urls.set(url_key(url), ready)+      }+      for (const {url} of lib.modules ?? []) {+        this.urls.set(url_key(url), ready)+      }+    }+  }+   /**    * Whether a library is already available without loading anything.    *@@ -469,7 +530,14 @@ function install(): ResourceRegistry {   if (Array.isArray(queued)) {     global.__panel_resources_declared__ = []     for (const declared of queued) {-      registry.declare(declared)+      // A queued entry carrying `ready` is a claim: some other loader, i.e.+      // the notebook's RequireJS, is fetching those libraries already.+      const ready = declared?.ready+      if (ready != null && typeof ready.then === "function") {+        registry.claim(declared, ready)+      } else {+        registry.declare(declared)+      }     }   }   return registry
panel/package-lock.json4 + / 0
@@ -473,6 +473,7 @@       "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",       "dev": true,       "license": "MIT",+      "peer": true,       "dependencies": {         "@typescript-eslint/scope-manager": "8.56.1",         "@typescript-eslint/types": "8.56.1",@@ -686,6 +687,7 @@       "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",       "dev": true,       "license": "MIT",+      "peer": true,       "bin": {         "acorn": "bin/acorn"       },@@ -863,6 +865,7 @@       "integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==",       "dev": true,       "license": "MIT",+      "peer": true,       "workspaces": [         "packages/*"       ],@@ -1561,6 +1564,7 @@       "resolved": "https://registry.npmjs.org/preact/-/preact-10.28.4.tgz",       "integrity": "sha512-uKFfOHWuSNpRFVTnljsCluEFq57OKT+0QdOiQo8XWnQ/pSvg7OpX5eNOejELXJMWy+BwM2nobz0FkvzmnpCNsQ==",       "license": "MIT",+      "peer": true,       "funding": {         "type": "opencollective",         "url": "https://opencollective.com/preact"
panel/tests/conftest.py4 + / 1
@@ -88,7 +88,10 @@ def get_default_port():  def start_jupyter():     global JUPYTER_PORT, JUPYTER_PROCESS-    args = ['jupyter', 'server', '--port', str(JUPYTER_PORT), "--NotebookApp.token=''"]+    args = [+        'jupyter', 'server', '--port', str(JUPYTER_PORT), "--NotebookApp.token=''",+        "--ServerApp.jpserver_extensions={'nbclassic': True}",+    ]     JUPYTER_PROCESS = process = Popen(args, stdout=PIPE, stderr=PIPE, bufsize=1, encoding='utf-8')     deadline = time.monotonic() + JUPYTER_TIMEOUT     while True:
panel/tests/io/test_notebook.py210 + / 8
@@ -1,3 +1,5 @@+import re+ import pytest  pytest.importorskip("IPython")@@ -6,12 +8,17 @@  from panel.config import config, panel_extension from panel.io import resources as resources_module-from panel.io.notebook import ipywidget, load_notebook, replace_inline_css-from panel.io.resource_spec import resource_spec+from panel.io.notebook import (+    LOAD_MIME, 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, ) from panel.layout import Column+from panel.models.echarts import ECharts+from panel.models.perspective import Perspective from panel.models.tabulator import DataTabulator from panel.pane import Str from panel.widgets import TextEditor@@ -36,13 +43,19 @@ def notebook_bootstrap():     """     from bokeh.io.state import curstate     state, mode = curstate(), resources_module.RESOURCE_MODE+    notebook_resources = resources_module.NOTEBOOK_RESOURCES     notebook, notebook_type = state.notebook, state.notebook_type+    # `_SPEC_CACHE` is keyed in part on `RESOURCE_MODE`/`NOTEBOOK_RESOURCES`,+    # so it must not leak a spec between this bootstrap and other tests.+    _SPEC_CACHE.clear()     try:         load_notebook(inline=True)         yield     finally:         resources_module.RESOURCE_MODE = mode+        resources_module.NOTEBOOK_RESOURCES = notebook_resources         state._notebook, state._notebook_type = notebook, notebook_type+        _SPEC_CACHE.clear()   @jb_available@@ -100,20 +113,160 @@ def test_notebook_inline_css_stylesheets(nb_loaded):         assert isinstance(stylesheet, InlineStyleSheet)  -def test_notebook_resources_resolve_absolutely(notebook_bootstrap):+def test_notebook_inline_resources_shadow_amd_globals(monkeypatch, notebook_bootstrap):+    """+    Inline UMD bundles must assign their browser globals on RequireJS pages.++    See https://github.com/holoviz/panel/issues/8750.+    """+    published = []++    def publish_display_data(data, **kwargs):+        published.append(data)++    monkeypatch.setattr('IPython.display.publish_display_data', publish_display_data)++    load_notebook(inline=True)++    bootstrap = next(data[LOAD_MIME] for data in published if LOAD_MIME in data)+    assert 'function(Bokeh, define, module, exports)' in bootstrap+    assert 'window.requirejs.config' in bootstrap+++def test_require_components_only_includes_active_panel_extensions(monkeypatch):+    """Classic notebook RequireJS setup must not load inactive extensions."""+    from bokeh.core.has_props import _default_resolver++    monkeypatch.setattr(panel_extension, '_loaded_extensions', ['echarts'])+    # model_class_reverse_map is derived from Bokeh's resolver rather than being+    # a plain dict, so registering has to go through the resolver. The autouse+    # module_cleanup fixture unregisters Panel's models before every test and+    # they are only registered when their class is defined, which has already+    # happened, so re-registering here is what puts them back.+    monkeypatch.setitem(+        _default_resolver._known_models, 'panel.models.echarts.ECharts', ECharts+    )+    monkeypatch.setitem(+        _default_resolver._known_models, 'panel.models.perspective.Perspective', Perspective+    )++    configs, requirements, *_ = require_components()++    assert 'echarts' in requirements+    assert not any(name.startswith('perspective') for name in requirements)+    assert any('echarts' in config['paths'] for config in configs)+++def test_notebook_endpoint_alert_stays_in_notebook_output(monkeypatch, notebook_bootstrap):+    """+    Endpoint failures must update Panel's output rather than the notebook UI.+    """+    published = []++    def publish_display_data(data, **kwargs):+        published.append(data)++    monkeypatch.setattr('IPython.display.publish_display_data', publish_display_data)++    load_notebook(inline=False)++    bootstrap = next(data[LOAD_MIME] for data in published if LOAD_MIME in data)+    html = next(data['text/html'] for data in published if 'text/html' in data)+    error_id = re.search(r'<div id="([^"]+)" role="alert" hidden>', html).group(1)+    assert f'document.getElementById("{error_id}")' in bootstrap+    assert '(document.body || document.documentElement)' not in bootstrap+    assert 'function fallback_to_cdn(element, url, attribute, parent)' in bootstrap+    assert 'element.dataset.panelCdnFallback != null' in bootstrap+    assert 'const CDN_DIST = "https://cdn.holoviz.org/panel/' in bootstrap+++def test_notebook_vscode_resources_use_cdn(monkeypatch, notebook_bootstrap):+    """+    VS Code has no Jupyter extension endpoint to serve Panel resources.+    """+    published = []++    def publish_display_data(data, **kwargs):+        published.append(data)++    monkeypatch.setattr('IPython.display.publish_display_data', publish_display_data)++    with config.set(comms='vscode'):+        load_notebook(inline=False)++    bootstrap = next(data[LOAD_MIME] for data in published if LOAD_MIME in data)+    js_urls = next(line for line in bootstrap.splitlines() if 'const js_urls' in line)+    assert '/panel-preview/static/extensions/panel/' not in js_urls+    assert 'https://cdn.holoviz.org/panel/' in js_urls+++def test_notebook_ipywidgets_resources_use_cdn(monkeypatch, notebook_bootstrap):+    """+    ipywidgets mode (Voila and similar) has no Jupyter extension endpoint+    either: only a plain notebook kernel (``comms == 'default'``) does.+    """+    published = []++    def publish_display_data(data, **kwargs):+        published.append(data)++    monkeypatch.setattr('IPython.display.publish_display_data', publish_display_data)++    with config.set(comms='ipywidgets'):+        load_notebook(inline=False)++    bootstrap = next(data[LOAD_MIME] for data in published if LOAD_MIME in data)+    js_urls = next(line for line in bootstrap.splitlines() if 'const js_urls' in line)+    assert '/panel-preview/static/extensions/panel/' not in js_urls+    assert 'https://cdn.holoviz.org/panel/' in js_urls+++def test_notebook_checks_extension_only_for_default_comms(monkeypatch):+    """+    The proactive liveness check only makes sense where the endpoint is+    actually expected to exist, i.e. a plain notebook kernel.+    """+    from bokeh.io.state import curstate++    published = []++    def publish_display_data(data, **kwargs):+        published.append(data)++    monkeypatch.setattr('IPython.display.publish_display_data', publish_display_data)++    for comms in ('default', 'vscode', 'colab', 'ipywidgets'):+        published.clear()+        bk_state, mode = curstate(), resources_module.RESOURCE_MODE+        notebook_resources = resources_module.NOTEBOOK_RESOURCES+        notebook, notebook_type = bk_state.notebook, bk_state.notebook_type+        try:+            with config.set(comms=comms):+                load_notebook(inline=False)+            bootstrap = next(data[LOAD_MIME] for data in published if LOAD_MIME in data)+            checks = 'panel.min.js", {method: "HEAD"}' in bootstrap+            assert checks == (comms == 'default'), comms+        finally:+            resources_module.RESOURCE_MODE = mode+            resources_module.NOTEBOOK_RESOURCES = notebook_resources+            bk_state._notebook, bk_state._notebook_type = notebook, notebook_type+            _SPEC_CACHE.clear()+++def test_notebook_resources_use_jupyter_extension_endpoint(notebook_bootstrap):     """     A component rendered in a later cell builds its specification outside-    any resource mode block, and the notebook page cannot resolve a url-    into the static endpoint Panel serves for an application.+    any resource mode block, so it has to preserve the Jupyter extension+    endpoint selected by the notebook bootstrap.     """     spec = resource_spec(DataTabulator)     urls = [url for lib in spec['libs'] for url in lib['js']] + spec['css']      assert urls-    assert all(url.startswith('http') for url in urls)+    assert all(url.startswith('/panel-preview/static/extensions/panel/') for url in urls)  -def test_notebook_dynamic_component_resources_resolve_absolutely(+def test_notebook_dynamic_component_resources_use_jupyter_extension_endpoint(     nb_loaded, notebook_bootstrap ):     column = Column()@@ -125,7 +278,56 @@ def test_notebook_dynamic_component_resources_resolve_absolutely(     urls = [url for lib in model.external_resources['libs'] for url in lib['js']]      assert urls-    assert all(url.startswith('http') for url in urls)+    assert all(url.startswith('/panel-preview/static/extensions/panel/') for url in urls)+++@pytest.mark.filterwarnings(+    "ignore:Attempted to send message over Jupyter Comm.*:UserWarning"+)+def test_notebook_resources_do_not_reuse_spec_cached_before_bootstrap(nb_loaded):+    """+    Regression test for a CI failure under xdist: a spec cached by an+    earlier, unrelated test under the same resolved mode was handed back+    unchanged to a notebook test, dropping the Jupyter extension endpoint.+    ``resource_spec``'s cache key must include the effective notebook+    default, not just the resolved mode.++    The filter is unrelated to what this checks: Python only raises that+    warning the first time it's hit in the process, so whether it surfaces+    here depends on what other tests already ran.+    """+    from bokeh.io.state import curstate+    state, mode = curstate(), resources_module.RESOURCE_MODE+    notebook_resources = resources_module.NOTEBOOK_RESOURCES+    notebook, notebook_type = state.notebook, state.notebook_type+    try:+        # Simulates the "earlier, unrelated test": mode 'cdn', no notebook.+        resources_module.RESOURCE_MODE = 'cdn'+        widget = TextEditor()+        outside_notebook_urls = [+            url for lib in (widget.get_root().external_resources or {}).get('libs', [])+            for url in lib['js']+        ]+        assert not any('panel-preview' in url for url in outside_notebook_urls)++        # The notebook bootstrap now runs, selecting that same 'cdn' mode.+        load_notebook(inline=True)++        column = Column()+        column._repr_mimebundle_()+        editor = TextEditor()+        column.append(editor)++        (model, _) = list(editor._models.values())[0]+        urls = [url for lib in model.external_resources['libs'] for url in lib['js']]++        assert urls+        assert all(url.startswith('/panel-preview/static/extensions/panel/') for url in urls)+    finally:+        resources_module.RESOURCE_MODE = mode+        resources_module.NOTEBOOK_RESOURCES = notebook_resources+        state._notebook, state._notebook_type = notebook, notebook_type+        _SPEC_CACHE.clear()   def test_replace_inline_css_ignores_version_query():
panel/tests/io/test_resource_spec.py40 + / 0
@@ -1,9 +1,11 @@ import pytest  from bokeh.model import Model+from bokeh.server.contexts import BokehSessionContext  from panel.config import config, panel_extension as extension from panel.custom import JSComponent+from panel.io import resources as resources_module from panel.io.resource_spec import (     SPEC_VERSION, declared_specs, lazy_load_available, resource_spec, )@@ -376,3 +378,41 @@ def test_resource_spec_respects_rel_path(document):                 state.base_url = '/'                 state.rel_path = ''     assert all(url.startswith('../static/') for url in _spec_urls(spec))+++def test_resource_spec_ignores_stale_notebook_default_in_server_session(document):+    """+    A server started from within a notebook's kernel process (``pn.serve``,+    commonly with ``threaded=True``) must not inherit the notebook's+    process-wide resource default: a plain served app never registers the+    `panel-preview` endpoint, so every lazily-loaded resource would 404.+    """+    server_doc = document+    session_context = BokehSessionContext('test-session', object(), server_doc)+    server_doc._session_context = lambda: session_context++    old_notebook_resources = resources_module.NOTEBOOK_RESOURCES+    resources_module.NOTEBOOK_RESOURCES = True+    try:+        assert state._is_server_session is False  # no curdoc bound yet++        with set_curdoc(server_doc):+            assert state._is_server_session is True+            spec = resource_spec(DataTabulator, 'cdn')+        assert not any(+            'panel-preview/static/extensions/panel' in url for url in _spec_urls(spec)+        )++        # The same class, resolved for a plain notebook comm document (no+        # session context at all), must still get the endpoint: the fix+        # narrowly targets genuine server sessions, not every document.+        from bokeh.document import Document+        notebook_doc = Document()+        with set_curdoc(notebook_doc):+            assert state._is_server_session is False+            spec = resource_spec(DataTabulator, 'cdn')+        assert all(+            'panel-preview/static/extensions/panel' in url for url in _spec_urls(spec)+        )+    finally:+        resources_module.NOTEBOOK_RESOURCES = old_notebook_resources
panel/tests/io/test_resources.py40 + / 0
@@ -113,6 +113,46 @@ def test_resources_cdn():         f'https://cdn.bokeh.org/bokeh/{bk_prefix}/bokeh-mathjax-{bokeh_version}.min.js',     ] ++def test_notebook_resources_respect_jupyterhub_base_url():+    resource = f'{CDN_DIST}bundled/datatabulator/tabulator-tables@{TABULATOR_VERSION}/dist/js/tabulator.min.js'+    with edit_readonly(state):+        state.base_url = '/user/alice/'+    try:+        resolved = Resources(mode='cdn', notebook=True).adjust_paths([resource])+    finally:+        with edit_readonly(state):+            state.base_url = '/'++    assert resolved == [+        f'/user/alice/panel-preview/static/extensions/panel/bundled/datatabulator/'+        f'tabulator-tables@{TABULATOR_VERSION}/dist/js/tabulator.min.js'+    ]+++def test_notebook_resources_do_not_double_render_endpoint_root():+    """+    The render endpoint sets `rel_path` to its own `panel-preview` root+    already, so it must not be appended a second time on top of+    `base_url`, which the render endpoint also includes it in.+    """+    resource = f'{CDN_DIST}bundled/datatabulator/tabulator-tables@{TABULATOR_VERSION}/dist/js/tabulator.min.js'+    with edit_readonly(state):+        state.base_url = '/user/alice/panel-preview/'+        state.rel_path = '/user/alice/panel-preview'+    try:+        resolved = Resources(mode='cdn', notebook=True).adjust_paths([resource])+    finally:+        with edit_readonly(state):+            state.base_url = '/'+            state.rel_path = ''++    assert resolved == [+        f'/user/alice/panel-preview/static/extensions/panel/bundled/datatabulator/'+        f'tabulator-tables@{TABULATOR_VERSION}/dist/js/tabulator.min.js'+    ]++ def test_resources_server_absolute():     resources = Resources(mode='server', absolute=True, minified=True)     assert resources.js_raw == ['Bokeh.set_log_level("info");']
panel/tests/ui/io/nbclassic/components.ipynbadded74 + / 0
@@ -0,0 +1,74 @@+{+ "cells": [+  {+   "cell_type": "code",+   "execution_count": null,+   "id": "b665ec80-285d-4062-8c4f-48e1f63db97e",+   "metadata": {},+   "outputs": [],+   "source": [+    "import pandas as pd\n",+    "import panel as pn\n",+    "\n",+    "pn.extension(\n",+    "    'codeeditor', 'deckgl', 'echarts', 'filedropper', 'jsoneditor',\n",+    "    'katex', 'perspective', 'tabulator', 'terminal', 'vega',\n",+    "    comms='default', inline=False,\n",+    ")\n"+   ]+  },+  {+   "cell_type": "code",+   "execution_count": null,+   "id": "c239d395-16df-4291-8b0c-9ff831e2235e",+   "metadata": {},+   "outputs": [],+   "source": [+    "button = pn.widgets.Button(name='Increment')\n",+    "counter = pn.pane.Markdown('0', css_classes=['nbclassic-counter'])\n",+    "button.on_click(lambda event: setattr(counter, 'object', str(int(counter.object) + 1)))\n",+    "pn.Row(button, counter)\n"+   ]+  },+  {+   "cell_type": "code",+   "execution_count": null,+   "id": "3142bbe9-c976-4a82-8b88-defebd0db5d3",+   "metadata": {},+   "outputs": [],+   "source": [+    "data = pd.DataFrame({'x': [1, 2, 3], 'y': [4, 5, 6]})\n",+    "pn.Column(\n",+    "    pn.widgets.Tabulator(data, height=160),\n",+    "    pn.pane.Perspective(data, height=200),\n",+    "    pn.widgets.FileDropper(height=100),\n",+    "    pn.widgets.CodeEditor(value='print(\"Panel\")', height=100),\n",+    "    pn.widgets.JSONEditor(value={'answer': 42}, height=160),\n",+    "    pn.pane.ECharts({'xAxis': {'data': ['A', 'B']}, 'yAxis': {}, 'series': [{'type': 'bar', 'data': [1, 2]}]}, height=180),\n",+    "    pn.pane.Vega({\n",+    "        '$schema': 'https://vega.github.io/schema/vega-lite/v5.json',\n",+    "        'mark': 'bar',\n",+    "        'data': {\n",+    "            'values': [{'x': 'A', 'y': 1}]\n",+    "        },\n",+    "        'encoding': {\n",+    "            'x': {'field': 'x', 'type': 'nominal'},\n",+    "            'y': {'field': 'y', 'type': 'quantitative'}\n",+    "        }\n",+    "    }, height=180),\n",+    "    pn.pane.DeckGL({'initialViewState': {'longitude': 0, 'latitude': 0, 'zoom': 1}, 'layers': []}, height=180),\n",+    "    pn.widgets.Terminal(height=100),\n",+    "    pn.pane.LaTeX(r'E = mc^2'),\n",+    ")\n"+   ]+  }+ ],+ "metadata": {+  "language_info": {+   "name": "python",+   "pygments_lexer": "ipython3"+  }+ },+ "nbformat": 4,+ "nbformat_minor": 5+}
panel/tests/ui/io/test_lazy_resources.py25 + / 0
@@ -389,3 +389,28 @@ def app():         assert _errors(msgs) == []     finally:         pn.config.lazy_resources = True+++def test_lazy_component_ignores_stale_notebook_default(page):+    """+    A server started from a notebook's kernel process (``pn.serve``,+    commonly with ``threaded=True``, exactly how ``serve_component``+    starts one here) must not inherit that notebook's resource default:+    it never registers the `panel-preview` endpoint, so every+    lazily-loaded resource would 404.+    """+    from panel.io import resources as resources_module+    old_notebook_resources = resources_module.NOTEBOOK_RESOURCES+    resources_module.NOTEBOOK_RESOURCES = True+    try:+        def app():+            extension()+            _tabulator().servable()++        msgs, _ = serve_component(page, app)++        expect(page.locator('.pnx-tabulator.tabulator')).to_have_count(1, timeout=20000)+        assert _errors(msgs) == []+        assert _script_count(page, 'panel-preview') == 0+    finally:+        resources_module.NOTEBOOK_RESOURCES = old_notebook_resources
panel/tests/ui/io/test_nbclassic.pyadded156 + / 0
@@ -0,0 +1,156 @@+import pytest+import requests++pytest.importorskip("playwright")++from playwright.sync_api import expect++pytestmark = [pytest.mark.ui, pytest.mark.jupyter]+++@pytest.fixture+def nbclassic_server(jupyter_preview):+    """Use nbclassic as an extension of the shared Jupyter Server."""+    host, _ = jupyter_preview.split('/panel-preview/', 1)+    return f'{host}/nbclassic'+++def run_notebook(page, nbclassic_server, notebook_name, cells):+    """Open a notebook in nbclassic and run every cell through its UI."""+    host = nbclassic_server+    api_host = host.removesuffix('/nbclassic')+    notebook = {+        'cells': [+            {+                'cell_type': 'code',+                'execution_count': None,+                'metadata': {},+                'outputs': [],+                'source': cell.splitlines(keepends=True),+            }+            for cell in cells+        ],+        'metadata': {+            'kernelspec': {'display_name': 'Python 3', 'language': 'python', 'name': 'python3'},+            'language_info': {'name': 'python'},+        },+        'nbformat': 4,+        'nbformat_minor': 5,+    }+    session = requests.Session()+    session.get(f'{host}/tree', timeout=10).raise_for_status()+    response = session.put(+        f'{api_host}/api/contents/{notebook_name}.ipynb',+        json={'type': 'notebook', 'format': 'json', 'content': notebook},+        headers={'X-XSRFToken': session.cookies['_xsrf']},+        timeout=10,+    )+    response.raise_for_status()+    page.goto(f'{host}/notebooks/{notebook_name}.ipynb')+    expect(page.locator('#notebook-container .code_cell').first).to_be_visible()+    page.wait_for_function(+        "window.Jupyter?.notebook?._fully_loaded && "+        "window.Jupyter.notebook.kernel && !window.Jupyter.notebook.kernel_busy"+    )+    page.evaluate('Jupyter.notebook.execute_all_cells()')+    page.wait_for_function(+        'Jupyter.notebook.get_cells().every((cell) => cell.input_prompt_number != null)'+    )+    page.wait_for_function("window.Jupyter && !window.Jupyter.notebook.kernel_busy")+    errors = page.locator('.output_error').all_inner_texts()+    assert not errors, '\n'.join(errors)+++@pytest.mark.parametrize(+    ('extension', 'component', 'selector'),+    [+        ('tabulator', "pn.widgets.Tabulator(pd.DataFrame({'x': [1, 2, 3]}), height=160)", '.pnx-tabulator.tabulator'),+        ('perspective', "pn.pane.Perspective(pd.DataFrame({'x': [1, 2, 3]}), height=200)", 'perspective-viewer'),+        ('filedropper', 'pn.widgets.FileDropper(height=100)', '.filepond--root'),+        ('codeeditor', "pn.widgets.CodeEditor(value='print(1)', height=100)", '.ace_editor'),+        ('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,+            ),+        ),+        ('terminal', 'pn.widgets.Terminal(height=100)', '.xterm'),+        ('katex', "pn.pane.LaTeX(r'$E = mc^2$')", '.katex'),+    ],+    ids=[+        'tabulator', 'perspective', 'filedropper', 'codeeditor', 'jsoneditor',+        'echarts', 'vega', 'deckgl', 'terminal', 'katex',+    ],+)+def test_nbclassic_component_resources(page, nbclassic_server, extension, component, selector):+    """Load each bundled component independently through nbclassic resources."""+    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, [+        'import pandas as pd\nimport panel as pn',+        f"pn.extension('{extension}', comms='default', inline=False)",+        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+++def test_nbclassic_component_comm(page, nbclassic_server):+    """Verify that a nbclassic browser event reaches Panel's notebook comm."""+    counter_cell = (+        "button = pn.widgets.Button(name='Increment')\n"+        "counter = pn.pane.Markdown('0', css_classes=['nbclassic-counter'])\n"+        "button.on_click(lambda event: setattr(counter, 'object', str(int(counter.object) + 1)))\n"+        "pn.Row(button, counter)"+    )+    run_notebook(page, nbclassic_server, 'component_comm', [+        'import panel as pn\npn.extension(comms="default", inline=False)',+        counter_cell,+    ])++    page.get_by_role('button', name='Increment').click()+    expect(page.locator('.nbclassic-counter')).to_have_text('1')+++def test_nbclassic_warns_when_extension_missing(page, nbclassic_server):+    """+    The proactive liveness check must warn even when nothing else on the+    page happens to request a panel-preview resource that would otherwise+    trigger the reactive error path.+    """+    page.route(+        '**/panel-preview/static/extensions/panel/**',+        lambda route: route.fulfill(status=404, body='Not Found'),+    )+    run_notebook(page, nbclassic_server, 'extensioncheck', [+        "import panel as pn\npn.extension(comms='default', inline=False)",+    ])++    alert = page.locator('.output_area [role="alert"]')+    expect(alert).to_be_visible(timeout=15000)+    expect(alert).to_contain_text('Jupyter server extension')
pixi.toml2 + / 0
@@ -230,6 +230,8 @@ playwright-python = "1.61.*" pytest-playwright = "*" pytest-asyncio = "*" jupyter_server = "*"+nbclassic = "*"+notebook = "*" esbuild = "*" packaging = "*"