diff --git a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts index 4412344af4..c8b53aceb9 100644 --- a/packages/layout-engine/layout-bridge/src/incrementalLayout.ts +++ b/packages/layout-engine/layout-bridge/src/incrementalLayout.ts @@ -89,6 +89,64 @@ export const measureCache = new MeasureCache(); 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); @@ -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; + let idsByColumn: Map>; + let measuresById: Map; + 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; + let finalIdsByColumn: Map>; + let finalBlocks: FlowBlock[]; + let finalMeasuresById: Map; + 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; @@ -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; + } const plan = finalPlan.reserves; const applied = reservesAppliedToLayout; const len = Math.max(plan.length, applied.length); @@ -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(); finalBlocks.forEach((block) => {