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
19 changes: 19 additions & 0 deletions .changeset/fix-full-code-review.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@modernrelay/orbit-core": patch
"@modernrelay/orbit-data": patch
"@modernrelay/orbit-engine-cosmos": patch
"@modernrelay/orbit-omnigraph": patch
"@modernrelay/orbit-react": patch
---

Prevent stale worker results and view restores from replacing newer datasets,
preserve saved layouts before mounting, and restore scale domains and fold
counts during undo. Visible exports now respect edge filters.

Fix table filtering after empty results and refresh navigator and tooltip
content when the scene changes. Keep fit-view zoom limits effective during
position transitions.

Normalize nested Arrow values and Omnigraph temporal lists, honor cancellation
through the final ingestion boundary, and avoid collisions between generated
node and edge type names.
50 changes: 50 additions & 0 deletions apps/demo/e2e/deep-link.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

import { expect, test } from '@playwright/test';
import type { Page } from '@playwright/test';
import type { GraphViewState } from '@modernrelay/orbit-core';

const READY_DOT = '[data-testid="status-dot"][title="ready"]';
const SELECTED_COUNT = '[data-testid="selected-count"]';
Expand Down Expand Up @@ -111,6 +112,55 @@ test('a corrupted ?view= applies NOTHING (the atomic rule, adversarially)', asyn
await expect(page.locator(SELECTED_COUNT)).toHaveText('3,000');
});

test('share → reload restores selected styling and keeps the style controls live', async ({ page }) => {
await page.goto('/');
await ready(page);
const simulation = page.locator('[data-orbit-toolbar-button="simulation"]');
if (await simulation.getAttribute('aria-pressed') === 'true') await simulation.click();

// Use the semantic surface to select one node without GPU picking. A
// nonempty controlled selection exercises aggregate host reflection.
await page.getByTestId('navigator-toggle').click();
await page.getByRole('option').first().press('Space');
await expect(page.locator(SELECTED_COUNT)).toHaveText('1');
await page.getByTestId('navigator-toggle').click();
await page.getByTestId('node-color-mode').selectOption('degree');
await page.getByTestId('node-size-mode').selectOption('scale');
await page.getByTestId('edge-arrows').check();
await page.getByTestId('show-links').uncheck();
await page.getByTestId('theme-light').check();
await page.getByTestId('share-view').click();
await page.waitForFunction(() => window.location.search.includes('view='));
const url = page.url();
const saved = JSON.parse(new URL(url).searchParams.get('view')!) as GraphViewState;
expect(saved.selection.nodeIds).toHaveLength(1);
expect(saved.styling).toMatchObject({ showLinks: false, edgeArrows: true, theme: 'light' });

await page.goto(url);
await ready(page);
await expect(page.locator(SELECTED_COUNT)).toHaveText('1');
await expect(page.getByTestId('app-root')).toHaveAttribute('data-theme', 'light');
await expect(page.getByTestId('node-color-mode')).toHaveValue('degree');
await expect(page.getByTestId('node-size-mode')).toHaveValue('scale');
await expect(page.getByTestId('edge-arrows')).toBeChecked();
await expect(page.getByTestId('show-links')).not.toBeChecked();
await expect(page.getByTestId('legend-panel')).toContainText('degree');
await expect(page.getByTestId('legend-panel-size')).toBeVisible();

// Re-sharing reads actual instance state, so these assertions cover more
// than the controls' checked/value attributes.
await page.getByTestId('share-view').click();
const restored = JSON.parse(new URL(page.url()).searchParams.get('view')!) as GraphViewState;
expect(restored.selection).toEqual(saved.selection);
expect(restored.styling).toEqual(saved.styling);

await page.getByTestId('node-color-mode').selectOption('category');
await page.getByTestId('node-size-mode').selectOption('accessor');
await page.getByTestId('show-links').check();
await expect(page.getByTestId('legend-panel').locator('[data-orbit-legend-row]')).toHaveCount(6);
await expect(page.getByTestId('legend-panel-size')).toHaveCount(0);
});

test('a stale dataRef trips the mismatch banner; Restore anyway opts in', async ({ page }) => {
await page.goto('/');
await ready(page);
Expand Down
55 changes: 55 additions & 0 deletions apps/demo/e2e/fit-view-transition.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { expect, test } from '@playwright/test';

// Serve a blank same-origin page so this regression exercises the real
// adapter/Cosmos pair without the demo's own camera and simulation controls.
const engineUrl = `/@fs${new URL('../../../packages/engine-cosmos/src/CosmosEngine.ts', import.meta.url).pathname}`;

for (const scenario of [
{ name: 'shrinking', before: 1000, after: 10, zoom: 1.5 },
{ name: 'expanding', before: 10, after: 1000, zoom: 0.8 },
]) {
test(`fitView uses ${scenario.name} transition destinations and respects maxZoom`, async ({ page }) => {
await page.route('**/__engine_fit_test__', (route) =>
route.fulfill({ contentType: 'text/html', body: '<!doctype html><html><body></body></html>' }),
);
await page.goto('/__engine_fit_test__');
const result = await page.evaluate(async ({ url, before, after }) => {
const { CosmosEngine } = await import(url) as typeof import('@modernrelay/orbit-engine-cosmos');
const host = document.createElement('div');
host.style.cssText = 'width:1000px;height:1000px';
document.body.appendChild(host);
const engine = new CosmosEngine({
initialConfig: { enableSimulation: false, rescalePositions: false, transitionDuration: 1000 },
});
const frames = () => new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
});
try {
await engine.mount(host, {});
engine.commit({
revision: 1,
structure: { pointCount: 2, positions: Float32Array.of(0, 0, before, before), links: new Uint32Array() },
});
await frames();
engine.commit({
revision: 2,
structure: { pointCount: 2, positions: Float32Array.of(0, 0, after, after), links: new Uint32Array() },
});
const atFit = Array.from(engine.getPositions()!);
engine.fitView({ maxZoom: 1.5, durationMs: 0 });
await frames();
return { atFit, viewport: engine.getViewport() };
} finally {
engine.destroy();
host.remove();
}
}, { url: engineUrl, before: scenario.before, after: scenario.after });

// Confirm the test reached a transition: the GPU still holds the old
// positions, while the fit must center and size for the destination.
expect(result.atFit).toEqual([0, 0, scenario.before, scenario.before]);
expect(result.viewport?.zoom).toBeCloseTo(scenario.zoom, 5);
expect(result.viewport?.x).toBeCloseTo(scenario.after / 2, 5);
expect(result.viewport?.y).toBeCloseTo(scenario.after / 2, 5);
});
}
85 changes: 69 additions & 16 deletions apps/demo/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ import type {
GraphSnapshot,
GraphPerfSnapshot,
GraphStoreState,
GraphViewState,
GroupSpec,
InstanceStatus,
LabelConfig,
Expand Down Expand Up @@ -594,6 +595,9 @@ export function App() {
// --- style state (Style panel) ---
const [themeBase, setThemeBase] = useState<ThemeBase>('dark');
const [colorMode, setColorMode] = useState<ColorMode>('category');
const [restoredNodeColor, setRestoredNodeColor] = useState<Scale<string, AppNodeAttrs> | null>(
null,
);
const [showLabelType, setShowLabelType] = useState(true);
/**
* fold counts mirrored from `store.folds` (subscribed below, once
Expand All @@ -606,6 +610,9 @@ export function App() {
*/
const [foldCounts, setFoldCounts] = useState<ReadonlyMap<NodeId, number>>(EMPTY_FOLD_COUNTS);
const [sizeMode, setSizeMode] = useState<SizeMode>('accessor');
const [restoredNodeSize, setRestoredNodeSize] = useState<Scale<number, AppNodeAttrs> | null>(
null,
);
const [edgeArrows, setEdgeArrows] = useState(false);
const [showLinks, setShowLinks] = useState(true);

Expand Down Expand Up @@ -764,18 +771,20 @@ export function App() {
// Stream mode keeps the plain accessor in 'category' mode (its cluster
// count differs from the declared declarative domain).
const nodeColorProp: Scale<string, AppNodeAttrs> | typeof nodeColor =
colorMode === 'degree'
restoredNodeColor ??
(colorMode === 'degree'
? DEGREE_COLOR_SCALE
: mode.kind === 'omnigraph'
? ogColorScale ?? nodeColor
: mode.kind === 'declarative'
? CLUSTER_COLOR_SCALE
: nodeColor;
: nodeColor);

// M5 keeps a fixed point size in accessor mode (see M5_NODE_SIZE); the
// degree Scale still applies when the Style panel selects it.
const nodeSizeProp: Scale<number, AppNodeAttrs> | typeof nodeSize | typeof M5_NODE_SIZE =
sizeMode === 'scale' ? DEGREE_SIZE_SCALE : mode.kind === 'semantic' ? M5_NODE_SIZE : nodeSize;
restoredNodeSize ??
(sizeMode === 'scale' ? DEGREE_SIZE_SCALE : mode.kind === 'semantic' ? M5_NODE_SIZE : nodeSize);

const filter = useMemo<FilterSpec<AppNodeAttrs, AppEdgeAttrs> | null>(() => {
if (mode.kind === 'omnigraph') {
Expand Down Expand Up @@ -1192,18 +1201,56 @@ export function App() {
[mode.kind, gen],
);

/** aggregate reflection: selection is this app's ONE controlled
* lane, so the intent reduces to reflecting it in one commit. */
const onViewStateRestore = useCallback((intent: { next: unknown }) => {
const next = intent.next as { selection?: { nodeIds?: readonly string[] } };
setSelection([...(next.selection?.nodeIds ?? [])]);
/** Keep both the graph props and their visible controls aligned with a
* restored view. Retain serialized scale descriptors verbatim; choosing a
* new style in the panel releases the restored override. */
const reflectStyling = useCallback((styling: GraphViewState['styling']) => {
if (styling === undefined) return;
if (styling.theme !== undefined) setThemeBase(styling.theme);
if (styling.showLinks !== undefined) setShowLinks(styling.showLinks);
if (styling.edgeArrows !== undefined) setEdgeArrows(styling.edgeArrows);
if (styling.nodeColor !== undefined) {
setRestoredNodeColor(styling.nodeColor);
setColorMode(styling.nodeColor.kind === 'categorical' ? 'category' : 'degree');
}
if (styling.nodeSize !== undefined) {
setRestoredNodeSize(styling.nodeSize);
setSizeMode('scale');
}
}, []);

/** A controlled restore delegates styling to the host too. Reflect every
* participating prop in this same React commit before acknowledging it. */
const onViewStateRestore = useCallback((intent: { next: unknown }) => {
const next = intent.next as GraphViewState;
setSelection([...next.selection.nodeIds]);
setM5Pinned([...next.pinnedNodeIds]);
if (m5GroupingRef.current === 'manual') {
setM5Groups(next.groups.filter((group): group is GroupSpec => 'id' in group));
}
reflectStyling(next.styling);
}, [reflectStyling]);

const restoreView = useCallback(async (raw: unknown, ignoreMismatch = false) => {
const instance = graphRef.current?.instance;
if (instance === undefined) return undefined;
const result = await instance.setViewState(raw, { ignoreMismatch });
// With no changed controlled slices, core restores internally and emits
// no aggregate intent. Mirror styling into the controls after admission.
if (result.status === 'applied' && graphRef.current?.instance === instance) {
reflectStyling((raw as GraphViewState).styling);
}
return result;
}, [reflectStyling]);

/** Share the current view: ?view= in the URL bar + clipboard best-effort. */
const shareView = useCallback(() => {
const handle = graphRef.current;
if (handle === null) return;
const state = handle.getViewState();
// The demo customizes the base theme's background, which the core
// intentionally omits. The host still knows its serializable base.
state.styling = { ...state.styling, theme: themeBase };
const url = new URL(window.location.href);
url.searchParams.set('view', JSON.stringify(state));
// Deep-links are for HUMAN-scale state. A pathological state (say, all
Expand All @@ -1219,7 +1266,7 @@ export function App() {
}
void navigator.clipboard?.writeText(text).catch(() => {});
window.setTimeout(() => setShareNote(null), 4000);
}, []);
}, [themeBase]);

/** exports. Each mirrors its output onto window.__lastExport (the
* e2e observability hook) and triggers a real download. The ?svgcap= param
Expand Down Expand Up @@ -1291,21 +1338,21 @@ export function App() {
// the restored camera wins.
if (st.status !== 'ready' || st.viewport === null) return;
restoredRef.current = true;
void instance.setViewState(raw).then((result) => {
if (result.status === 'mismatch') setMismatchRaw(raw);
void restoreView(raw).then((result) => {
if (result?.status === 'mismatch') setMismatchRaw(raw);
});
};
attempt();
const unsubscribe = instance.store.subscribe(attempt);
return unsubscribe;
}, [mode.kind]);
}, [mode.kind, restoreView]);

const restoreAnyway = useCallback(() => {
const raw = mismatchRaw;
setMismatchRaw(null);
if (raw === null) return;
void graphRef.current?.instance.setViewState(raw, { ignoreMismatch: true });
}, [mismatchRaw]);
void restoreView(raw, true);
}, [mismatchRaw, restoreView]);

const graphKey =
mode.kind === 'stream'
Expand Down Expand Up @@ -1480,9 +1527,15 @@ export function App() {
themeBase={themeBase}
onThemeBaseChange={setThemeBase}
colorMode={colorMode}
onColorModeChange={setColorMode}
onColorModeChange={(next) => {
setRestoredNodeColor(null);
setColorMode(next);
}}
sizeMode={sizeMode}
onSizeModeChange={setSizeMode}
onSizeModeChange={(next) => {
setRestoredNodeSize(null);
setSizeMode(next);
}}
edgeArrows={edgeArrows}
onEdgeArrowsChange={setEdgeArrows}
showLinks={showLinks}
Expand Down
Loading
Loading