Skip to content
Open
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
102 changes: 102 additions & 0 deletions labextension/src/__tests__/metadataPersistenceDecision.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// Copyright 2026 The Kubeflow Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import type { NotebookPanel } from '@jupyterlab/notebook';
import { resolveMetadataPersistence } from '../widgets/hooks/metadataPersistenceDecision';

// Minimal NotebookPanel stub: the decision only reads `isDisposed` and uses
// object identity to distinguish notebooks.
function makeNotebook(isDisposed = false): NotebookPanel {
return { isDisposed } as unknown as NotebookPanel;
}

describe('resolveMetadataPersistence (#644 regression)', () => {
it('writes changed metadata to the owning notebook', () => {
const owner = makeNotebook();
const decision = resolveMetadataPersistence({
json: '{"pipeline_name":"a"}',
prevJson: '{"pipeline_name":""}',
isLoading: false,
loadedNotebook: owner,
});
expect(decision.shouldWrite).toBe(true);
expect(decision.target).toBe(owner);
});

it('targets the owning notebook, not the currently active tab', () => {
// This is the core of the #644 leak: the effect runs after a paint, and by
// then the active tab may be a different notebook. The write must still go
// to the notebook the metadata was loaded from.
const owningNotebook = makeNotebook();
const nowActiveNotebook = makeNotebook();

const decision = resolveMetadataPersistence({
json: '{"pipeline_name":"belongs-to-owner"}',
prevJson: '{"pipeline_name":""}',
isLoading: false,
loadedNotebook: owningNotebook,
});

expect(decision.shouldWrite).toBe(true);
expect(decision.target).toBe(owningNotebook);
// Never the active tab.
expect(decision.target).not.toBe(nowActiveNotebook);
});

it('does not write while the loader is populating state', () => {
const owner = makeNotebook();
const decision = resolveMetadataPersistence({
json: '{"pipeline_name":"a"}',
prevJson: '{"pipeline_name":""}',
isLoading: true,
loadedNotebook: owner,
});
expect(decision.shouldWrite).toBe(false);
expect(decision.target).toBeNull();
});

it('does not write when metadata is unchanged', () => {
const owner = makeNotebook();
const sameJson = '{"pipeline_name":"a"}';
const decision = resolveMetadataPersistence({
json: sameJson,
prevJson: sameJson,
isLoading: false,
loadedNotebook: owner,
});
expect(decision.shouldWrite).toBe(false);
});

it('does not write when there is no owning notebook', () => {
const decision = resolveMetadataPersistence({
json: '{"pipeline_name":"a"}',
prevJson: '{"pipeline_name":""}',
isLoading: false,
loadedNotebook: null,
});
expect(decision.shouldWrite).toBe(false);
expect(decision.target).toBeNull();
});

it('does not write when the owning notebook has been disposed', () => {
const disposed = makeNotebook(true);
const decision = resolveMetadataPersistence({
json: '{"pipeline_name":"a"}',
prevJson: '{"pipeline_name":""}',
isLoading: false,
loadedNotebook: disposed,
});
expect(decision.shouldWrite).toBe(false);
});
});
67 changes: 67 additions & 0 deletions labextension/src/widgets/hooks/metadataPersistenceDecision.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Copyright 2026 The Kubeflow Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import type { NotebookPanel } from '@jupyterlab/notebook';

export interface IPersistenceDecisionInput {
// JSON of the metadata about to be considered for persistence.
json: string;
// JSON of the metadata last seen by the effect (change detection).
prevJson: string;
// Whether the loader is currently populating metadata state.
isLoading: boolean;
// The notebook the current metadata belongs to (write target), or null.
loadedNotebook: NotebookPanel | null;
}

export interface IPersistenceDecision {
// Whether to write the metadata back to the notebook file.
shouldWrite: boolean;
// The notebook to write to, if shouldWrite is true.
target: NotebookPanel | null;
}

/**
* Pure decision for whether the current metadata should be written back to a
* notebook, and to which notebook.
*
* The critical rule (regression guard for #644): the write target is the
* notebook the metadata was loaded from (`loadedNotebook`), never the currently
* active tab. The persistence effect runs asynchronously after a paint, so by
* the time it runs the active tab may already be a different notebook; writing
* to the active tab would leak one notebook's metadata into another.
*
* Kept free of runtime imports so it can be unit-tested without pulling in the
* JupyterLab module graph.
*/
export function resolveMetadataPersistence({
json,
prevJson,
isLoading,
loadedNotebook,
}: IPersistenceDecisionInput): IPersistenceDecision {
// Unchanged metadata: nothing to persist.
if (json === prevJson) {
return { shouldWrite: false, target: null };
}
// Loader is populating state: these are reads, not user edits.
if (isLoading) {
return { shouldWrite: false, target: null };
}
// No owning notebook, or it has been closed: nowhere to write.
if (!loadedNotebook || loadedNotebook.isDisposed) {
return { shouldWrite: false, target: null };
}
return { shouldWrite: true, target: loadedNotebook };
}
Loading