Skip to content

Commit 443ff6b

Browse files
Merge pull request #16 from HarperFast/fix/scroll-reactive-header-settle
fix(browser): settle at top after scroll so scroll-reactive headers re-reveal (v1.6.0)
2 parents 534fc3a + ac006d0 commit 443ff6b

5 files changed

Lines changed: 29 additions & 4 deletions

File tree

packages/browser/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ include what you change:
9191
"networkIdleMs": 300,
9292
"networkIdleTimeoutMs": 1000,
9393
},
94-
"scroll": { "enabled": true, "stepMs": 200 }, // scroll to bottom to trigger lazy content
94+
"scroll": { "enabled": true, "stepMs": 200, "topSettleMs": 300 }, // scroll to bottom for lazy content; topSettleMs lets scroll-reactive headers re-reveal at the top before serializing
9595
"postProcess": {
9696
"stripScripts": true, // remove executable <script> (keeps application/ld+json etc.)
9797
"inlineEmptyStyleSheets": true,

packages/browser/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@harperfast/prerender-browser",
3-
"version": "1.5.3",
3+
"version": "1.6.0",
44
"type": "module",
55
"description": "Headless-browser render library for Harper Prerender: claims render jobs from the @harperfast/prerender queue, renders pages in headless Chrome (Puppeteer), and posts the HTML back. Embedded by a render service and configured entirely via startWorker() options.",
66
"keywords": [

packages/browser/src/config.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,15 @@ export type ScrollConfig = {
9696
* `navigation.domStableTolerance` controls how much late churn is ignored. Default 2.
9797
*/
9898
settleStablePasses: number;
99+
/**
100+
* After scrolling back to the top (end of the scroll/settle phase), wait this many ms
101+
* before serializing so scroll-reactive UI returns to its top state. Sticky/compact
102+
* headers commonly hide the main header on scroll-down and re-reveal it only at the
103+
* top via a throttled scroll handler that runs a tick *after* `scrollTo(0, 0)` — with
104+
* no wait, the snapshot captures the header mid-hide (a blank band). `0` disables the
105+
* wait. Default 300.
106+
*/
107+
topSettleMs: number;
99108
};
100109

101110
export type PostProcessConfig = {
@@ -166,7 +175,7 @@ export const defaultConfig = (): PrerenderConfig => ({
166175
domStablePollMs: 250,
167176
domStableTolerance: 8,
168177
},
169-
scroll: { enabled: true, stepMs: 200, settleUntilStable: false, settleStablePasses: 2 },
178+
scroll: { enabled: true, stepMs: 200, settleUntilStable: false, settleStablePasses: 2, topSettleMs: 300 },
170179
postProcess: {
171180
stripScripts: true,
172181
inlineEmptyStyleSheets: true,

packages/browser/src/renderer.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,22 @@ const renderer: Renderer = async (page, job) => {
232232
else stablePasses = 0;
233233
last = count;
234234
}
235+
await scrollToTop();
236+
};
237+
238+
// Return to the top and let scroll-reactive UI settle before we serialize. Sticky/
239+
// compact headers hide the main header on scroll-down and re-reveal it only at the top
240+
// via a throttled scroll handler that fires a tick *after* scrollTo(0, 0); serializing
241+
// immediately captures the header mid-hide (a blank band). Hold for topSettleMs so that
242+
// handler runs first. The wait is a Node-side timer (not an in-page requestAnimationFrame
243+
// flush) on purpose: rAF can be paused indefinitely in a backgrounded headless tab, which
244+
// would hang the render — and since we serialize the DOM (not a paint), only the handler's
245+
// class flip needs to land, which the wall-clock wait covers regardless of how it's scheduled.
246+
const scrollToTop = async () => {
235247
await page.evaluate(() => window.scrollTo(0, 0)).catch(noop);
248+
if (config.scroll.topSettleMs > 0) {
249+
await new Promise((resolve) => setTimeout(resolve, config.scroll.topSettleMs));
250+
}
236251
};
237252

238253
if (config.scroll.enabled && config.scroll.settleUntilStable) {
@@ -243,7 +258,7 @@ const renderer: Renderer = async (page, job) => {
243258
// (e.g. so a scroll-aware navbar renders in its default state).
244259
await page.evaluate(scrollToBottom, config.scroll.stepMs);
245260
await networkIdle();
246-
await page.evaluate(() => window.scrollTo(0, 0));
261+
await scrollToTop();
247262
}
248263
await networkIdle();
249264
await domStable();

packages/browser/test/config.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ test('defaults reproduce the original hardcoded behavior', () => {
2222
assert.deepEqual(config.block.urlPatterns, []);
2323
assert.equal(config.navigation.waitUntil, 'domcontentloaded');
2424
assert.equal(config.scroll.enabled, true);
25+
assert.equal(config.scroll.topSettleMs, 300);
2526
assert.equal(config.postProcess.stripScripts, true);
2627
assert.equal(config.injectWebComponentsPolyfill, true);
2728
});

0 commit comments

Comments
 (0)