onSelectSection(section.id)}
>
- {section.displayName}
+ {node.displayName}
{section.parseStatus}
@@ -163,6 +205,7 @@ function NotebookTreeItems({
selectedPageId={selectedPageId}
onSelectPage={onSelectPage}
nested
+ renderLimits={renderLimits}
/>
) : selected ? (
@@ -181,10 +224,15 @@ export function PackageNavigation({
selectedPageId,
onSelectSection,
onSelectPage,
+ renderLimits,
}: PackageNavigationProps) {
- const sectionById = new Map(
- sections.map((section) => [section.id, section] as const)
- );
+ const projection = prepareNotebookForNavigation(tree.nodes, renderLimits);
+ const sectionById = new Map();
+ for (const section of sections) {
+ if (projection.sectionIds.has(section.id)) {
+ sectionById.set(section.id, section);
+ }
+ }
return (
@@ -193,20 +241,27 @@ export function PackageNavigation({
Notebook contents
{tree.interpreted ? 'OneNote notebook order' : 'Package order'}
- {tree.nodes.length === 0 ? (
+ {projection.nodes.length === 0 ? (
No section entries were found.
) : (
)}
+ {projection.truncated ? (
+
+ Notebook navigation was shortened to a safe browser display budget
+ after {projection.nodeCount} nodes.
+
+ ) : null}
);
}
diff --git a/src/components/ui-render-budget.test.tsx b/src/components/ui-render-budget.test.tsx
new file mode 100644
index 0000000..019a7d6
--- /dev/null
+++ b/src/components/ui-render-budget.test.tsx
@@ -0,0 +1,189 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { describe, expect, it, vi } from 'vitest';
+
+import type {
+ OneNoteNotebookNodeDto,
+ OneNotePackageDto,
+ OneNotePageSummaryDto,
+ OneNoteSectionDto,
+} from '../onenote/model/dto.js';
+import type { ParserDiagnostic } from '../onenote/model/diagnostics.js';
+import { DiagnosticsPanel } from './DiagnosticsPanel.js';
+import { PackageInspector } from './PackageInspector.js';
+import { SingleSectionNavigation } from './SectionNavigation.js';
+import {
+ prepareNotebookForNavigation,
+ preparePagesForNavigation,
+} from './ui-render-budget.js';
+
+describe('bounded UI projections', () => {
+ it('limits page count and display strings before building navigation nodes', () => {
+ const projection = preparePagesForNavigation(
+ [
+ page('first', 'Long title', 'Long preview'),
+ page('second'),
+ page('third'),
+ ],
+ {
+ maxNavigationPages: 2,
+ maxNavigationTitleCharacters: 5,
+ maxNavigationPreviewCharacters: 6,
+ }
+ );
+
+ expect(projection.pages).toHaveLength(2);
+ expect(projection.pages[0]).toMatchObject({
+ title: 'Long…',
+ text: '',
+ textPreview: 'Long …',
+ });
+ expect(projection.truncated).toBe(true);
+ });
+
+ it('bounds notebook recursion and rejects cyclic DTO branches', () => {
+ const cyclic: OneNoteNotebookNodeDto = {
+ kind: 'section-group',
+ id: 'cycle',
+ displayName: 'Cycle',
+ children: [],
+ };
+ cyclic.children.push(cyclic);
+
+ const cycleProjection = prepareNotebookForNavigation([cyclic], {
+ maxNotebookNodes: 10,
+ maxNotebookDepth: 10,
+ });
+ expect(cycleProjection.nodes).toEqual([
+ {
+ kind: 'section-group',
+ id: 'cycle',
+ displayName: 'Cycle',
+ children: [],
+ },
+ ]);
+ expect(cycleProjection.truncated).toBe(true);
+
+ const countProjection = prepareNotebookForNavigation(
+ Array.from({ length: 20 }, (_, index) => notebookSection(index)),
+ { maxNotebookNodes: 3 }
+ );
+ expect(countProjection.nodeCount).toBe(3);
+ expect(countProjection.nodes).toHaveLength(3);
+ expect(countProjection.truncated).toBe(true);
+ });
+
+ it('renders only the configured number of section page links with a notice', () => {
+ render(
+ page(`${index}`))
+ )}
+ onSelectPage={vi.fn()}
+ renderLimits={{ maxNavigationPages: 2 }}
+ />
+ );
+
+ expect(screen.getAllByRole('button')).toHaveLength(2);
+ expect(screen.getByText('0')).toBeVisible();
+ expect(screen.queryByText('2')).not.toBeInTheDocument();
+ expect(screen.getByRole('status')).toHaveTextContent('first 2 of 5 pages');
+ });
+});
+
+describe('bounded paged inspectors', () => {
+ it('paginates package entries instead of mounting the complete table', async () => {
+ const user = userEvent.setup();
+ render( );
+
+ await user.click(
+ screen.getByText('Inspect 5 package entries', { selector: 'summary' })
+ );
+ expect(screen.getByText('entry-0.one')).toBeVisible();
+ expect(screen.getByText('entry-1.one')).toBeVisible();
+ expect(screen.queryByText('entry-2.one')).not.toBeInTheDocument();
+
+ await user.click(screen.getByRole('button', { name: 'Next' }));
+ expect(screen.queryByText('entry-0.one')).not.toBeInTheDocument();
+ expect(screen.getByText('entry-2.one')).toBeVisible();
+ expect(screen.getByText('Showing entries 3–4 of 5.')).toBeVisible();
+ });
+
+ it('paginates diagnostics and truncates the visible message', async () => {
+ const user = userEvent.setup();
+ const diagnostics = Array.from({ length: 5 }, (_, index) =>
+ diagnostic(index, index === 0 ? 'x'.repeat(10_000) : `message-${index}`)
+ );
+ render( );
+
+ expect(document.querySelectorAll('.diagnostic')).toHaveLength(2);
+ expect(document.querySelector('.diagnostic p')?.textContent).toHaveLength(
+ 4_096
+ );
+ expect(screen.queryByText('message-2')).not.toBeInTheDocument();
+ expect(screen.getByRole('status')).toHaveTextContent(
+ 'Long diagnostic text was shortened'
+ );
+
+ await user.click(screen.getByRole('button', { name: 'Next' }));
+ expect(document.querySelectorAll('.diagnostic')).toHaveLength(2);
+ expect(screen.getByText('message-2')).toBeVisible();
+ expect(screen.getByText('Showing diagnostics 3–4 of 5.')).toBeVisible();
+ });
+});
+
+function page(id: string, title = id, textPreview = ''): OneNotePageSummaryDto {
+ return { id, title, text: `body-${id}`, textPreview };
+}
+
+function section(pages: OneNotePageSummaryDto[]): OneNoteSectionDto {
+ return {
+ format: 'one',
+ sourceName: 'section.one',
+ sectionName: 'Section',
+ pages,
+ diagnostics: [],
+ parserInfo: {
+ implementation: 'typescript',
+ supportedVariant: 'test',
+ referenceRevision: 'test',
+ },
+ };
+}
+
+function notebookSection(index: number): OneNoteNotebookNodeDto {
+ return {
+ kind: 'section',
+ id: `node-${index}`,
+ sectionId: `section-${index}`,
+ displayName: `Section ${index}`,
+ };
+}
+
+function notebook(entryCount: number): OneNotePackageDto {
+ return {
+ format: 'onepkg',
+ sourceName: 'notebook.onepkg',
+ sourceSize: 100,
+ entries: Array.from({ length: entryCount }, (_, index) => ({
+ path: `entry-${index}.one`,
+ normalizedPath: `entry-${index}.one`,
+ kind: 'section' as const,
+ uncompressedSize: 10,
+ compressionMethod: 'MSZIP',
+ parseStatus: 'parsed' as const,
+ })),
+ sections: [],
+ tree: { interpreted: false, nodes: [] },
+ diagnostics: [],
+ };
+}
+
+function diagnostic(index: number, message: string): ParserDiagnostic {
+ return {
+ severity: 'warning',
+ code: `TEST_${index}`,
+ message,
+ recoverable: true,
+ };
+}
diff --git a/src/components/ui-render-budget.ts b/src/components/ui-render-budget.ts
new file mode 100644
index 0000000..5b4b7c8
--- /dev/null
+++ b/src/components/ui-render-budget.ts
@@ -0,0 +1,213 @@
+import type {
+ OneNoteNotebookNodeDto,
+ OneNotePageSummaryDto,
+} from '../onenote/model/dto.js';
+
+export interface UiRenderLimits {
+ maxNavigationPages: number;
+ maxNotebookNodes: number;
+ maxNotebookDepth: number;
+ maxNavigationTitleCharacters: number;
+ maxNavigationPreviewCharacters: number;
+ maxPackageEntriesPerPage: number;
+ maxPackagePathCharacters: number;
+ maxPackageMethodCharacters: number;
+ maxDiagnosticsPerPage: number;
+ maxDiagnosticCodeCharacters: number;
+ maxDiagnosticMessageCharacters: number;
+ maxDiagnosticContextCharacters: number;
+ maxPanelTitleCharacters: number;
+}
+
+export const DEFAULT_UI_RENDER_LIMITS: Readonly = {
+ maxNavigationPages: 250,
+ maxNotebookNodes: 500,
+ maxNotebookDepth: 16,
+ maxNavigationTitleCharacters: 512,
+ maxNavigationPreviewCharacters: 1_000,
+ maxPackageEntriesPerPage: 200,
+ maxPackagePathCharacters: 1_024,
+ maxPackageMethodCharacters: 128,
+ maxDiagnosticsPerPage: 100,
+ maxDiagnosticCodeCharacters: 128,
+ maxDiagnosticMessageCharacters: 4_096,
+ maxDiagnosticContextCharacters: 512,
+ maxPanelTitleCharacters: 512,
+};
+
+export type NavigationRenderLimits = Pick<
+ UiRenderLimits,
+ | 'maxNavigationPages'
+ | 'maxNotebookNodes'
+ | 'maxNotebookDepth'
+ | 'maxNavigationTitleCharacters'
+ | 'maxNavigationPreviewCharacters'
+>;
+
+export interface PageNavigationProjection {
+ pages: OneNotePageSummaryDto[];
+ truncated: boolean;
+}
+
+export interface NotebookNavigationProjection {
+ nodes: OneNoteNotebookNodeDto[];
+ sectionIds: ReadonlySet;
+ nodeCount: number;
+ truncated: boolean;
+}
+
+/** Limit a caller-provided value without permitting the safety cap to grow. */
+export function resolveUiLimit(
+ value: number | undefined,
+ safetyCap: number
+): number {
+ if (value === undefined || !Number.isSafeInteger(value) || value < 1) {
+ return safetyCap;
+ }
+ return Math.min(value, safetyCap);
+}
+
+export function limitUiString(
+ value: string,
+ maxCharacters: number
+): { value: string; truncated: boolean } {
+ const limit = Math.max(1, Math.trunc(maxCharacters));
+ if (value.length <= limit) return { value, truncated: false };
+ return {
+ value: `${value.slice(0, Math.max(0, limit - 1))}…`,
+ truncated: true,
+ };
+}
+
+/** Project page summaries before React builds a nested page list. */
+export function preparePagesForNavigation(
+ pages: readonly OneNotePageSummaryDto[],
+ overrides: Partial = {}
+): PageNavigationProjection {
+ const maxPages = resolveUiLimit(
+ overrides.maxNavigationPages,
+ DEFAULT_UI_RENDER_LIMITS.maxNavigationPages
+ );
+ const maxTitleCharacters = resolveUiLimit(
+ overrides.maxNavigationTitleCharacters,
+ DEFAULT_UI_RENDER_LIMITS.maxNavigationTitleCharacters
+ );
+ const maxPreviewCharacters = resolveUiLimit(
+ overrides.maxNavigationPreviewCharacters,
+ DEFAULT_UI_RENDER_LIMITS.maxNavigationPreviewCharacters
+ );
+ const projected: OneNotePageSummaryDto[] = [];
+ let truncated = pages.length > maxPages;
+
+ for (let index = 0; index < pages.length && index < maxPages; index += 1) {
+ const page = pages[index]!;
+ const title = limitUiString(page.title, maxTitleCharacters);
+ const preview = limitUiString(page.textPreview, maxPreviewCharacters);
+ truncated ||= title.truncated || preview.truncated;
+ projected.push({
+ id: page.id,
+ level: page.level,
+ title: title.value,
+ text: '',
+ textPreview: preview.value,
+ });
+ }
+
+ return { pages: projected, truncated };
+}
+
+/** Project the notebook hierarchy with aggregate node and recursion budgets. */
+export function prepareNotebookForNavigation(
+ nodes: readonly OneNoteNotebookNodeDto[],
+ overrides: Partial = {}
+): NotebookNavigationProjection {
+ const limits = {
+ maxNotebookNodes: resolveUiLimit(
+ overrides.maxNotebookNodes,
+ DEFAULT_UI_RENDER_LIMITS.maxNotebookNodes
+ ),
+ maxNotebookDepth: resolveUiLimit(
+ overrides.maxNotebookDepth,
+ DEFAULT_UI_RENDER_LIMITS.maxNotebookDepth
+ ),
+ maxNavigationTitleCharacters: resolveUiLimit(
+ overrides.maxNavigationTitleCharacters,
+ DEFAULT_UI_RENDER_LIMITS.maxNavigationTitleCharacters
+ ),
+ };
+ const budget = {
+ count: 0,
+ truncated: false,
+ active: new Set(),
+ sectionIds: new Set(),
+ };
+
+ const projected = projectNotebookNodes(nodes, 0, limits, budget);
+ return {
+ nodes: projected,
+ sectionIds: budget.sectionIds,
+ nodeCount: budget.count,
+ truncated: budget.truncated,
+ };
+}
+
+function projectNotebookNodes(
+ nodes: readonly OneNoteNotebookNodeDto[],
+ depth: number,
+ limits: Pick<
+ NavigationRenderLimits,
+ 'maxNotebookNodes' | 'maxNotebookDepth' | 'maxNavigationTitleCharacters'
+ >,
+ budget: {
+ count: number;
+ truncated: boolean;
+ active: Set;
+ sectionIds: Set;
+ }
+): OneNoteNotebookNodeDto[] {
+ const projected: OneNoteNotebookNodeDto[] = [];
+ for (const node of nodes) {
+ if (budget.count >= limits.maxNotebookNodes) {
+ budget.truncated = true;
+ break;
+ }
+ if (budget.active.has(node)) {
+ budget.truncated = true;
+ continue;
+ }
+
+ budget.count += 1;
+ const displayName = limitUiString(
+ node.displayName,
+ limits.maxNavigationTitleCharacters
+ );
+ budget.truncated ||= displayName.truncated;
+
+ if (node.kind === 'section') {
+ budget.sectionIds.add(node.sectionId);
+ projected.push({
+ kind: 'section',
+ id: node.id,
+ sectionId: node.sectionId,
+ displayName: displayName.value,
+ });
+ continue;
+ }
+
+ budget.active.add(node);
+ let children: OneNoteNotebookNodeDto[] = [];
+ if (depth >= limits.maxNotebookDepth) {
+ if (node.children.length > 0) budget.truncated = true;
+ } else {
+ children = projectNotebookNodes(node.children, depth + 1, limits, budget);
+ }
+ budget.active.delete(node);
+ projected.push({
+ kind: 'section-group',
+ id: node.id,
+ displayName: displayName.value,
+ children,
+ });
+ }
+ return projected;
+}