Skip to content
Open
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
233 changes: 174 additions & 59 deletions packages/layout-engine/layout-bridge/src/incrementalLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,64 @@ export const measureCache = new MeasureCache<Measure>();
const headerMeasureCache = new HeaderFooterLayoutCache();
const headerFooterCacheState = new HeaderFooterCacheState();

/**
* Cache for footnote convergence loop results.
* The key is a stable signature of footnote IDs (not positions, which shift on every keystroke).
* When the same footnotes exist, we can skip the expensive multi-pass convergence loop.
*/
class FootnoteConvergenceCache {
private footnoteIds: string = '';
private reserves: number[] = [];
private separatorSpacingBefore: number = 0;

/**
* Compute a stable signature from footnote IDs only.
* Positions change on every keystroke, but IDs remain stable until footnotes are added/removed.
*/
private static computeSignature(refs: Array<{ id: string; pos: number }>): string {
if (!refs || refs.length === 0) return '';
const ids = refs.map((r) => r.id).sort();
return ids.join('|');
}

/** Check if the cache has a valid hit for the given footnote refs. */
checkHit(refs: Array<{ id: string; pos: number }>): boolean {
const signature = FootnoteConvergenceCache.computeSignature(refs);
return signature !== '' && signature === this.footnoteIds && this.reserves.some((h) => h > 0);
}

/** Get cached reserves (returns a copy). */
getReserves(): number[] {
return this.reserves.slice();
}

/** Get cached separator spacing. */
getSeparatorSpacingBefore(): number {
return this.separatorSpacingBefore;
}

/** Update the cache with new converged values. */
update(refs: Array<{ id: string; pos: number }>, reserves: number[], separatorSpacingBefore: number): void {
this.footnoteIds = FootnoteConvergenceCache.computeSignature(refs);
this.reserves = reserves.slice();
this.separatorSpacingBefore = separatorSpacingBefore;
}

/** Clear the cache. */
clear(): void {
this.footnoteIds = '';
this.reserves = [];
this.separatorSpacingBefore = 0;
}
}

const footnoteConvergenceCache = new FootnoteConvergenceCache();

/** Clear the footnote convergence cache (e.g., on document reload). */
export const clearFootnoteConvergenceCache = (): void => {
footnoteConvergenceCache.clear();
};

const layoutDebugEnabled =
typeof process !== 'undefined' && typeof process.env !== 'undefined' && Boolean(process.env.SD_DEBUG_LAYOUT);

Expand Down Expand Up @@ -2338,72 +2396,121 @@ export async function incrementalLayout(
// mid-loop, which would zero their entries and cause oscillation.
const allFootnoteIds = new Set(footnotesInput.refs.map((ref) => ref.id));

// Pass 1: assign + reserve from current layout. Pre-measure ALL footnote
// bodies (the cache makes the assigned-only subset essentially free).
let { columns: pageColumns, idsByColumn } = resolveFootnoteAssignments(layout);
let { measuresById } = await measureFootnoteBlocks(allFootnoteIds);
refreshBodyHeights(measuresById);
let plan = computeFootnoteLayoutPlan(layout, idsByColumn, measuresById, [], pageColumns);
let reserves = plan.reserves;
// Check footnote convergence cache - skip expensive multi-pass loop if footnotes unchanged
const cacheHit = footnoteConvergenceCache.checkHit(footnotesInput.refs);
let usedCachedReserves = false;

// Declare variables used by both cache hit and miss paths
let pageColumns: Map<number, PageColumns>;
let idsByColumn: Map<number, Map<number, string[]>>;
let measuresById: Map<string, Measure>;
let plan: FootnoteLayoutPlan;
let reserves: number[];

// Cache hit fast path: skip Pass 1 entirely, use cached reserves directly
if (cacheHit) {
reserves = footnoteConvergenceCache.getReserves();
const separatorSpacingBefore = footnoteConvergenceCache.getSeparatorSpacingBefore();
layout = relayout(reserves, separatorSpacingBefore);
({ columns: pageColumns, idsByColumn } = resolveFootnoteAssignments(layout));
const measured = await measureFootnoteBlocks(allFootnoteIds);
measuresById = measured.measuresById;
refreshBodyHeights(measuresById);
plan = computeFootnoteLayoutPlan(layout, idsByColumn, measuresById, reserves, pageColumns);
usedCachedReserves = true;
console.log('[layout] Footnote convergence cache HIT - skipped Pass 1 and loop');
} else {
// Cache miss: do Pass 1 to get initial reserves
({ columns: pageColumns, idsByColumn } = resolveFootnoteAssignments(layout));
({ measuresById } = await measureFootnoteBlocks(allFootnoteIds));
refreshBodyHeights(measuresById);
plan = computeFootnoteLayoutPlan(layout, idsByColumn, measuresById, [], pageColumns);
reserves = plan.reserves;
}

// Relayout with footnote reserves and iterate until reserves and page count stabilize,
// so each page gets the correct reserve (avoids "too much" on one page and "not enough" on another).
if (reserves.some((h) => h > 0)) {
let reservesStabilized = false;
const seenReserveVectors: number[][] = [reserves.slice()];
for (let pass = 0; pass < MAX_FOOTNOTE_LAYOUT_PASSES; pass += 1) {
layout = relayout(reserves, plan.separatorSpacingBefore);
({ columns: pageColumns, idsByColumn } = resolveFootnoteAssignments(layout));
// SD-3049: measure the full set each iteration so `bodyHeightById`
// stays complete; refs migrating between pages must not drop their
// measured demand from the per-block lookup.
({ measuresById } = await measureFootnoteBlocks(allFootnoteIds));
refreshBodyHeights(measuresById);
plan = computeFootnoteLayoutPlan(layout, idsByColumn, measuresById, reserves, pageColumns);
const nextReserves = plan.reserves;
const reservesStable =
nextReserves.length === reserves.length &&
nextReserves.every((h, i) => (reserves[i] ?? 0) === h) &&
reserves.every((h, i) => (nextReserves[i] ?? 0) === h);
if (reservesStable) {
reserves = nextReserves;
reservesStabilized = true;
break;
// Declare final* variables that will be set by either cache hit or convergence loop
let finalPageColumns: Map<number, PageColumns>;
let finalIdsByColumn: Map<number, Map<number, string[]>>;
let finalBlocks: FlowBlock[];
let finalMeasuresById: Map<string, Measure>;
let finalPlan: FootnoteLayoutPlan;
let reservesAppliedToLayout: number[];

// Cache hit: use already-computed values from fast path above
if (usedCachedReserves) {
const measured = await measureFootnoteBlocks(allFootnoteIds);
finalBlocks = measured.blocks;
finalPageColumns = pageColumns;
finalIdsByColumn = idsByColumn;
finalMeasuresById = measuresById;
finalPlan = plan;
reservesAppliedToLayout = reserves;
console.log('[layout] Footnote convergence cache HIT - skipped convergence loop');
} else {
// Cache miss: run full convergence loop
let reservesStabilized = false;
const seenReserveVectors: number[][] = [reserves.slice()];
for (let pass = 0; pass < MAX_FOOTNOTE_LAYOUT_PASSES; pass += 1) {
layout = relayout(reserves, plan.separatorSpacingBefore);
({ columns: pageColumns, idsByColumn } = resolveFootnoteAssignments(layout));
// SD-3049: measure the full set each iteration so `bodyHeightById`
// stays complete; refs migrating between pages must not drop their
// measured demand from the per-block lookup.
({ measuresById } = await measureFootnoteBlocks(allFootnoteIds));
refreshBodyHeights(measuresById);
plan = computeFootnoteLayoutPlan(layout, idsByColumn, measuresById, reserves, pageColumns);
const nextReserves = plan.reserves;
const reservesStable =
nextReserves.length === reserves.length &&
nextReserves.every((h, i) => (reserves[i] ?? 0) === h) &&
reserves.every((h, i) => (nextReserves[i] ?? 0) === h);
if (reservesStable) {
reserves = nextReserves;
reservesStabilized = true;
break;
}
// Reserves are oscillating. Break out; the post-reserve grow loop
// below (which is monotonic and has its own cycle detector) will
// bump any under-reserved pages to the current plan's demand.
// Merging history here would carry over large demands from early
// passes that the current layout no longer anchors, leading to
// wasted reserved space on pages that never get any footnote.
if (seenReserveVectors.some((v) => v.join(',') === nextReserves.join(','))) break;
seenReserveVectors.push(nextReserves.slice());
// Only update reserves when we will do another layout pass; otherwise layout
// would be built with the previous reserves while reserves would be nextReserves,
// and the plan/injection phase could place footnotes in the wrong band.
if (pass < MAX_FOOTNOTE_LAYOUT_PASSES - 1) {
reserves = nextReserves;
}
}
// Reserves are oscillating. Break out; the post-reserve grow loop
// below (which is monotonic and has its own cycle detector) will
// bump any under-reserved pages to the current plan's demand.
// Merging history here would carry over large demands from early
// passes that the current layout no longer anchors, leading to
// wasted reserved space on pages that never get any footnote.
if (seenReserveVectors.some((v) => v.join(',') === nextReserves.join(','))) break;
seenReserveVectors.push(nextReserves.slice());
// Only update reserves when we will do another layout pass; otherwise layout
// would be built with the previous reserves while reserves would be nextReserves,
// and the plan/injection phase could place footnotes in the wrong band.
if (pass < MAX_FOOTNOTE_LAYOUT_PASSES - 1) {
reserves = nextReserves;
if (!reservesStabilized) {
console.warn(
`[incrementalLayout] Footnote reserve loop did not converge (max ${MAX_FOOTNOTE_LAYOUT_PASSES} passes); layout may have suboptimal footnote placement.`,
);
}
}
if (!reservesStabilized) {
console.warn(
`[incrementalLayout] Footnote reserve loop did not converge (max ${MAX_FOOTNOTE_LAYOUT_PASSES} passes); layout may have suboptimal footnote placement.`,

// Update cache with converged values
footnoteConvergenceCache.update(footnotesInput.refs, reserves, plan.separatorSpacingBefore ?? 0);
console.log('[layout] Footnote convergence cache MISS - updated cache');

({ columns: finalPageColumns, idsByColumn: finalIdsByColumn } = resolveFootnoteAssignments(layout));
({ blocks: finalBlocks, measuresById: finalMeasuresById } = await measureFootnoteBlocks(
collectFootnoteIdsByColumn(finalIdsByColumn),
));
finalPlan = computeFootnoteLayoutPlan(
layout,
finalIdsByColumn,
finalMeasuresById,
reserves,
finalPageColumns,
);
reservesAppliedToLayout = reserves;
}

let { columns: finalPageColumns, idsByColumn: finalIdsByColumn } = resolveFootnoteAssignments(layout);
let { blocks: finalBlocks, measuresById: finalMeasuresById } = await measureFootnoteBlocks(
collectFootnoteIdsByColumn(finalIdsByColumn),
);
let finalPlan = computeFootnoteLayoutPlan(
layout,
finalIdsByColumn,
finalMeasuresById,
reserves,
finalPageColumns,
);
let reservesAppliedToLayout = reserves;

const vectorsEqual = (a: number[], b: number[]): boolean => {
for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
if ((a[i] ?? 0) !== (b[i] ?? 0)) return false;
Expand Down Expand Up @@ -2610,6 +2717,11 @@ export async function incrementalLayout(
// up to ~20 unnecessary relayouts on documents without oscillation.
const TIGHTEN_SLACK_PX = 8;
const needsWork = (() => {
// Cache hit: skip grow/tighten loops entirely — reserves are already optimal
if (usedCachedReserves) {
console.log('[layout] Skipping grow/tighten loops (using cached reserves)');
return false;
}
Comment on lines +2720 to +2724

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Validate cached footnote reserves before skipping correction

When the same footnote IDs remain but their anchors move to different pages/columns (for example, typing above a footnote or opening another document with the same generated IDs), the cached page-index reserve vector can no longer match finalPlan.reserves. The code recomputes finalPlan after applying the cache, but this early return prevents the existing grow/tighten safety loop from fixing under-reserved or dead-reserved pages, so injectFragments can place footnotes into a page that was not reserved for them and overflow the band. Let the finalPlan vs reservesAppliedToLayout checks run on cache hits, or make the cache key include the page/column assignment and other layout inputs that affect reserves.

Useful? React with 👍 / 👎.

const plan = finalPlan.reserves;
const applied = reservesAppliedToLayout;
const len = Math.max(plan.length, applied.length);
Expand Down Expand Up @@ -2702,8 +2814,11 @@ export async function incrementalLayout(
await applyReserves(safeApplied);
}
};
await runWidowOrphanAbsorb();
await runPreferredReserveTrials();
// Skip post-processing when using cached reserves — already optimal
if (!usedCachedReserves) {
await runWidowOrphanAbsorb();
await runPreferredReserveTrials();
}

const blockById = new Map<string, FlowBlock>();
finalBlocks.forEach((block) => {
Expand Down
Loading