Skip to content

Commit a123e6a

Browse files
committed
ntp: support scroll restoration
1 parent 726dc76 commit a123e6a

File tree

12 files changed

+103
-20
lines changed

12 files changed

+103
-20
lines changed

special-pages/01.png

44.7 KB
Loading

special-pages/02.png

81.4 KB
Loading

special-pages/03.png

80.5 KB
Loading

special-pages/end.png

80.4 KB
Loading

special-pages/end2.png

81.8 KB
Loading

special-pages/pages/new-tab/app/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { DocumentVisibilityProvider } from '../../../shared/components/DocumentV
1818
import { applyDefaultStyles } from './customizer/utils.js';
1919
import { TabsService } from './tabs/tabs.service.js';
2020
import { TabsDebug, TabsProvider } from './tabs/TabsProvider.js';
21+
import { PersistentScrollProvider } from './tabs/ScrollRestore.js';
2122

2223
/**
2324
* @import {Telemetry} from "./telemetry/telemetry.js"
@@ -133,6 +134,7 @@ export async function init(root, messaging, telemetry, baseEnvironment) {
133134
entryPoints={entryPoints}
134135
>
135136
<TabsProvider service={tabs}>
137+
<PersistentScrollProvider />
136138
{environment.urlParams.has('tabs.debug') && <TabsDebug />}
137139
<App />
138140
</TabsProvider>

special-pages/pages/new-tab/app/omnibar/components/PersistentOmnibarValuesProvider.js

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useCallback, useContext, useEffect, useState } from 'preact/hooks';
33
import { OmnibarContext, useOmnibarService } from './OmnibarProvider.js';
44
import { useTabState } from '../../tabs/TabsProvider.js';
55
import { PersistentValue } from '../../tabs/PersistentValue.js';
6+
import { invariant } from '../../utils.js';
67

78
/**
89
* @typedef {import("../../../types/new-tab.js").OmnibarConfig["mode"]} Mode
@@ -119,14 +120,3 @@ export function useModeWithLocalPersistence(tabId, defaultMode) {
119120

120121
return mode;
121122
}
122-
123-
/**
124-
* @param {any} condition
125-
* @param {string} [message]
126-
* @return {asserts condition}
127-
*/
128-
export function invariant(condition, message) {
129-
if (condition) return;
130-
if (message) throw new Error('Invariant failed: ' + message);
131-
throw new Error('Invariant failed');
132-
}

special-pages/pages/new-tab/app/omnibar/integration-tests/omnibar.persistence.spec.js

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,18 @@ test.describe('omnibar widget persistence', () => {
1515
await omnibar.types({ mode: 'search', value: 'shoes' });
1616

1717
// switch
18-
await omnibar.didSwitchToTab('02', ['01', '02']);
18+
await ntp.didSwitchToTab('02', ['01', '02']);
1919
await omnibar.expectInputValue('');
2020

2121
// second fill
2222
await omnibar.types({ mode: 'search', value: 'dresses' });
2323

2424
// back to first
25-
await omnibar.didSwitchToTab('01', ['01', '02']);
25+
await ntp.didSwitchToTab('01', ['01', '02']);
2626
await omnibar.expectInputValue('shoes');
2727

2828
// back to second again
29-
await omnibar.didSwitchToTab('02', ['01', '02']);
29+
await ntp.didSwitchToTab('02', ['01', '02']);
3030
await omnibar.expectInputValue('dresses');
3131
});
3232
test('remembers `mode` across tabs', async ({ page }, workerInfo) => {
@@ -41,11 +41,11 @@ test.describe('omnibar widget persistence', () => {
4141
await page.getByRole('tab', { name: 'Duck.ai' }).click();
4242

4343
// new tab, should be opened with duck.ai input still visible
44-
await omnibar.didSwitchToTab('02', ['01', '02']);
44+
await ntp.didSwitchToTab('02', ['01', '02']);
4545
await omnibar.expectChatValue('');
4646

4747
// switch back
48-
await omnibar.didSwitchToTab('01', ['01', '02']);
48+
await ntp.didSwitchToTab('01', ['01', '02']);
4949
await omnibar.expectChatValue('shoes');
5050
});
5151
test('adjusts mode of other tabs when duck.ai is disabled', async ({ page }, workerInfo) => {

special-pages/pages/new-tab/app/tabs/PersistentValue.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/**
2-
* @template {string} T - the value to hold.
2+
* @template {string|number} T - the value to hold.
33
*/
44
export class PersistentValue {
55
/** @type {Map<string, T>} */
@@ -17,7 +17,7 @@ export class PersistentValue {
1717
* @param {T} args.value
1818
*/
1919
update({ id, value }) {
20-
if (string(id) && string(value)) {
20+
if (string(id) && value !== null && value !== undefined) {
2121
this.#values.set(id, value);
2222
}
2323
}
@@ -63,20 +63,20 @@ export class PersistentValue {
6363
byId(id) {
6464
if (typeof id !== 'string') return null;
6565
const value = this.#values.get(id);
66-
if (!value || !string(value)) return null;
66+
if (value === null || value === undefined) return null;
6767
return value;
6868
}
6969

7070
print() {
7171
for (const [key, value] of this.#values) {
7272
console.log(`key: ${key}, value: ${value}`);
7373
}
74-
console.log('--');
7574
}
7675
}
7776

7877
/**
7978
* @param {unknown} input
79+
* @returns {string}
8080
*/
8181
function string(input) {
8282
if (typeof input !== 'string') return '';
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { useEffect, useState } from 'preact/hooks';
2+
import { useTabState } from './TabsProvider.js';
3+
import { PersistentValue } from './PersistentValue.js';
4+
import { invariant } from '../utils.js';
5+
6+
const SCROLLER = '[data-main-scroller]';
7+
8+
/**
9+
* Allow recording and restoring of the scroll position
10+
*/
11+
export function PersistentScrollProvider() {
12+
const [value] = useState(() => /** @type {PersistentValue<number>} */ (new PersistentValue()));
13+
const { all, current } = useTabState();
14+
useEffect(() => {
15+
let last = current.peek();
16+
const elem = document.querySelector(SCROLLER);
17+
invariant(elem, 'must have access to scroller here');
18+
19+
/**
20+
* Subscribe to changes in available tab IDs to prune stored scroll positions
21+
* for tabs that no longer exist
22+
*/
23+
const unsub1 = all.subscribe((tabIds) => {
24+
value?.prune({ preserve: tabIds });
25+
});
26+
/**
27+
* Subscribe to changes in the current tab to restore the saved scroll position
28+
* when switching between tabs. If no scroll position is saved, defaults to 0.
29+
*/
30+
const unsub2 = current.subscribe((tabId) => {
31+
last = tabId;
32+
const restore = value.byId(last);
33+
const nextY = restore ?? 0;
34+
35+
elem.scrollTop = nextY;
36+
});
37+
38+
const controller = new AbortController();
39+
elem.addEventListener(
40+
'scroll',
41+
(e) => {
42+
if (!(e.target instanceof HTMLElement)) throw new Error('unreachable');
43+
value.update({ id: last, value: e.target.scrollTop });
44+
},
45+
{ signal: controller.signal },
46+
);
47+
48+
return () => {
49+
unsub1();
50+
unsub2();
51+
controller.abort();
52+
};
53+
}, [all, current, value]);
54+
return null;
55+
}

0 commit comments

Comments
 (0)