Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@

## Bug fixes

- Fix the graph widget failing to render in PyCharm and VS Code

## Improvements

- Reduce the widget payload.

## Other changes
4 changes: 4 additions & 0 deletions js-applet/src/graph-widget.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ type WidgetState = {
} | null;
visible?: boolean;
};
_css?: string;
};

// The static HTML render path uses the real `createLocalModel` shim, so tests
Expand Down Expand Up @@ -137,6 +138,9 @@ async function renderWidgetInShadowRoot(
theme: "light",
selected: { nodeIds: [], relationshipIds: [] },
legend: { nodes: null, relationships: null, visible: true },
// The stylesheet is host-provided (`_css`); a stub is enough to assert the
// shadow-root/document.head bridging.
_css: ".nvl-test-ndl { color: red; }",
});

let teardown: RenderedWidget["teardown"] = undefined;
Expand Down
77 changes: 43 additions & 34 deletions js-applet/src/graph-widget.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { createRender, useModel, useModelState } from "@anywidget/react";
import ndlCssText from "@neo4j-ndl/base/lib/neo4j-ds-styles.css?inline";
import { Gesture, GraphSelection, GraphVisualization } from "@neo4j-ndl/react-graph";
import type NVL from "@neo4j-nvl/base";
import type { Layout, NvlOptions } from "@neo4j-nvl/base";
Expand Down Expand Up @@ -78,6 +77,11 @@ export type WidgetData = {
selected: GraphSelection;
legend: LegendData;
last_event: InteractionEvent | null;
/**
* The NDL stylesheet, provided by the host (anywidget syncs the widget's `_css`
* trait; Streamlit passes it in the component state). Never bundled into the JS.
*/
_css?: string;
};

const EMPTY_SELECTION: GraphSelection = { nodeIds: [], relationshipIds: [] };
Expand Down Expand Up @@ -154,49 +158,50 @@ function useResolvedTheme(theme: Theme | undefined): "light" | "dark" {
return normalizedTheme === "auto" ? detectedTheme : normalizedTheme;
}

// @font-face rules in shadow DOM adopted stylesheets don't register fonts at the
// document level, so the browser can't find them for rendering. We extract and hoist
// them into document.head eagerly at module load so fonts begin loading immediately.
const fontFaceRules = (ndlCssText.match(/@font-face\s*\{[^}]*\}/g) || []).join("\n");
if (fontFaceRules) {
const fontStyle = document.createElement("style");
fontStyle.textContent = fontFaceRules;
document.head.appendChild(fontStyle);
}

const documentStyleSelector = "[data-neo4j-viz-ndl-main]";
const overlayStyleSelector = "[data-neo4j-viz-ndl-overlays]";
const shadowRootStyleSelector = "[data-neo4j-viz-ndl-shadow-root]";

function appendStyle(root: Node & ParentNode, attributeName: string, cssText: string) {
const documentStyleAttr = "data-neo4j-viz-ndl-main";
const overlayStyleAttr = "data-neo4j-viz-ndl-overlays";
const shadowRootStyleAttr = "data-neo4j-viz-ndl-shadow-root";
const fontStyleAttr = "data-neo4j-viz-ndl-fonts";

// The NDL stylesheet is deliberately NOT imported into the JS bundle: it is provided
// by the host via the model's `_css` trait (anywidget syncs it, Streamlit passes it in
// the component state). Bundling it here duplicated ~2 MB of CSS into every widget
// payload. `ensureStyle` keeps the injected copies in sync so `_css` updates (HMR) are
// reflected without adding duplicate <style> elements.
function ensureStyle(root: Node & ParentNode, attributeName: string, cssText: string) {
const existing = root.querySelector<HTMLStyleElement>(`[${attributeName}]`);
if (existing) {
if (existing.textContent !== cssText) existing.textContent = cssText;
return;
}
const style = document.createElement("style");
style.setAttribute(attributeName, "true");
style.textContent = cssText;
root.appendChild(style);
}

// @font-face rules in shadow DOM adopted stylesheets don't register fonts at the
// document level, so the browser can't find them for rendering. We extract and hoist
// them into document.head so fonts load immediately.
function hoistFontFaces(cssText: string) {
const fontFaceRules = (cssText.match(/@font-face\s*\{[^}]*\}/g) || []).join("\n");
if (fontFaceRules) ensureStyle(document.head, fontStyleAttr, fontFaceRules);
}

/**
* Injects the full NDL stylesheet into the appropriate scope. In shadow DOM
* contexts (e.g. Marimo notebooks), widget content stays styled inside the
* shadow root and portaled overlays get the same stylesheet in document.head.
* Injects the NDL stylesheet into the appropriate scope. In shadow DOM contexts
* (e.g. Marimo notebooks), widget content stays styled inside the shadow root and
* portaled overlays get the same stylesheet in document.head.
*/
function injectNdlCss(el: HTMLElement) {
function injectNdlCss(el: HTMLElement, cssText: string) {
const rootNode = el.getRootNode();
if (rootNode instanceof ShadowRoot) {
if (!rootNode.querySelector(shadowRootStyleSelector)) {
appendStyle(rootNode, "data-neo4j-viz-ndl-shadow-root", ndlCssText);
}

if (!document.head.querySelector(overlayStyleSelector)) {
appendStyle(document.head, "data-neo4j-viz-ndl-overlays", ndlCssText);
}

ensureStyle(rootNode, shadowRootStyleAttr, cssText);
ensureStyle(document.head, overlayStyleAttr, cssText);
return;
}

if (!document.head.querySelector(documentStyleSelector)) {
appendStyle(document.head, "data-neo4j-viz-ndl-main", ndlCssText);
}
ensureStyle(document.head, documentStyleAttr, cssText);
}

/**
Expand Down Expand Up @@ -249,6 +254,9 @@ function GraphWidget() {
const [selected, setSelected] = useModelState<WidgetData["selected"]>("selected");
const [, setLastEvent] = useModelState<WidgetData["last_event"]>("last_event");
const [legend] = useModelState<WidgetData["legend"]>("legend");
// The NDL stylesheet is synced by the host as `_css` (see injectNdlCss) instead of
// being bundled into the JS payload.
const [ndlCss] = useModelState<string>("_css");
const {
layout,
nvlOptions,
Expand Down Expand Up @@ -276,9 +284,10 @@ function GraphWidget() {
const resolvedTheme = useResolvedTheme(theme);

useEffect(() => {
if (!wrapperRef.current) return;
injectNdlCss(wrapperRef.current);
}, []);
if (!wrapperRef.current || !ndlCss) return;
hoistFontFaces(ndlCss);
injectNdlCss(wrapperRef.current, ndlCss);
}, [ndlCss]);

// NVL sizes its <canvas> once at mount via an internal `element-resize-event` scroll-sensor
// polyfill that doesn't fire when the side panel (NDL Drawer, type "push") flex-shrinks its
Expand Down
6 changes: 5 additions & 1 deletion js-applet/src/streamlit-entrypoint.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import ndlCssText from "@neo4j-ndl/base/lib/neo4j-ds-styles.css?inline";

import widget, { type Theme, type WidgetData } from "./graph-widget";

/**
Expand Down Expand Up @@ -161,7 +163,9 @@ export default function render(component: ComponentArg): () => void {

if (!mounted) {
const model = new StreamlitModel(setStateValue);
model.initialize(incoming);
// Streamlit has no `_css` trait; provide the stylesheet so the widget can inject
// it into the shadow root and (for portaled overlays) document.head.
model.initialize({ ...incoming, _css: ndlCssText });

const el = document.createElement("div");
// Size the container before mounting so NVL initializes at the correct size.
Expand Down
33 changes: 33 additions & 0 deletions js-applet/vite-plugin-clean-worker-chunks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import fs from "node:fs";
import path from "node:path";
import type { Plugin, ResolvedConfig } from "vite";

// NVL ships its layout web workers as separate chunks (e.g.
// `CoseBilkentLayout.worker-<hash>.js`), but the widget forces
// `disableWebWorkers: true`, so they are never fetched. Vite still emits them as
// build assets (and `emptyOutDir: false` lets stale ones accumulate), so drop them
// after each build to keep the resources directory (and wheel) free of dead weight.
const WORKER_FILE_RE = /\.worker-[A-Za-z0-9_-]+\.js$/;

export function cleanWorkerChunks(): Plugin {
let outDir = "";

return {
name: "neo4j-viz:clean-worker-chunks",
apply: "build",
configResolved(config: ResolvedConfig) {
outDir = path.resolve(config.root, config.build.outDir);
},
closeBundle() {
if (!outDir) return;
for (const dir of [outDir, path.join(outDir, "assets")]) {
if (!fs.existsSync(dir)) continue;
for (const entry of fs.readdirSync(dir)) {
if (WORKER_FILE_RE.test(entry)) {
fs.rmSync(path.join(dir, entry), { force: true });
}
}
}
},
};
}
6 changes: 5 additions & 1 deletion js-applet/vite.config.html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@ import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";

import { cleanWorkerChunks } from "./vite-plugin-clean-worker-chunks.ts";

// HTML build: produces a single self-contained index.html with all JS and
// CSS inlined. Python injects graph data at runtime as an inert
// <script type="application/json" id="neo4j-viz-data"> block before serving.
export default defineConfig({
plugins: [react(), viteSingleFile()],
plugins: [react(), viteSingleFile(), cleanWorkerChunks()],
define: {
"process.env.NODE_ENV": JSON.stringify("production"),
// See vite.config.ts: neutralize the UMD/AMD `define()` branch of bundled deps.
"define.amd": "undefined",
},
build: {
outDir: "../python-wrapper/src/neo4j_viz/resources/nvl_entrypoint",
Expand Down
6 changes: 5 additions & 1 deletion js-applet/vite.config.streamlit.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";

import { cleanWorkerChunks } from "./vite-plugin-clean-worker-chunks.ts";

// Streamlit build: produces an ES module (graph.js + style.css) shipped as package
// data and passed inline as the `js`/`css` of an st.components.v2.component (see
// python-wrapper/src/neo4j_viz/streamlit.py). The module's default export is the v2
// mount function in src/streamlit-entrypoint.ts.
export default defineConfig({
plugins: [react()],
plugins: [react(), cleanWorkerChunks()],
define: {
// React reads process.env.NODE_ENV at runtime.
"process.env.NODE_ENV": JSON.stringify("production"),
// See vite.config.ts: neutralize the UMD/AMD `define()` branch of bundled deps.
"define.amd": "undefined",
},
build: {
outDir: "../python-wrapper/src/neo4j_viz/resources/streamlit_v2",
Expand Down
10 changes: 9 additions & 1 deletion js-applet/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,22 @@ import anywidget from "@anywidget/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";

import { cleanWorkerChunks } from "./vite-plugin-clean-worker-chunks.ts";

// ESM lib build for anywidget (produces widget.js + style.css).
// Dev server: `yarn dev` starts Vite with HMR via @anywidget/vite.
// Python widget points _esm at http://localhost:5173/src/index.tsx?anywidget
export default defineConfig({
plugins: [react(), anywidget()],
plugins: [react(), anywidget(), cleanWorkerChunks()],
define: {
// React reference process.env.NODE_ENV at runtime
"process.env.NODE_ENV": JSON.stringify("production"),
// Transitive UMD deps (lodash, classnames, layout-base, cose-base, RBush, ...)
// call the AMD `define()` when a loader is present. Hosts that load requirejs
// (PyCharm, VS Code) then throw "Mismatched anonymous define() module" while
// evaluating the widget module, which aborts it (anywidget's 2s init timeout
// fires -> "Failed to initialize model"). Neutralize the AMD branch.
"define.amd": "undefined",
},
build: {
outDir: "../python-wrapper/src/neo4j_viz/resources/nvl_entrypoint",
Expand Down
210 changes: 105 additions & 105 deletions python-wrapper/src/neo4j_viz/resources/nvl_entrypoint/index.html

Large diffs are not rendered by default.

14,836 changes: 7,419 additions & 7,417 deletions python-wrapper/src/neo4j_viz/resources/nvl_entrypoint/widget.js

Large diffs are not rendered by default.

15,397 changes: 7,701 additions & 7,696 deletions python-wrapper/src/neo4j_viz/resources/streamlit_v2/graph.js

Large diffs are not rendered by default.

Loading