cursedbelt 5.76.0 → 5.78.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/react/canvas-hud/CanvasToolbar.d.ts +1 -1
- package/dist/react/components/BuildFreshness.d.ts +2 -1
- package/dist/react/components/BuildFreshness.d.ts.map +1 -1
- package/dist/react/components/BuildFreshness.js +11 -4
- package/dist/react/components/BuildFreshness.js.map +1 -1
- package/dist/react/components/DataTable/TableFilterBuilder.d.ts.map +1 -1
- package/dist/react/components/DataTable/TableFilterBuilder.js +2 -2
- package/dist/react/components/DataTable/TableFilterBuilder.js.map +1 -1
- package/dist/react/components/ListToolbar.d.ts +2 -2
- package/dist/react/components/ListToolbar.d.ts.map +1 -1
- package/dist/react/components/ListToolbar.js +1 -1
- package/dist/react/hooks/useOutsidePress.d.ts.map +1 -1
- package/dist/react/hooks/useOutsidePress.js +18 -3
- package/dist/react/hooks/useOutsidePress.js.map +1 -1
- package/dist/react/hooks/useScrollMemory.d.ts +4 -3
- package/dist/react/hooks/useScrollMemory.d.ts.map +1 -1
- package/dist/react/hooks/useScrollMemory.js +16 -2
- package/dist/react/hooks/useScrollMemory.js.map +1 -1
- package/dist/react/media/FocusViewer.d.ts +8 -1
- package/dist/react/media/FocusViewer.d.ts.map +1 -1
- package/dist/react/media/FocusViewer.js +174 -10
- package/dist/react/media/FocusViewer.js.map +1 -1
- package/dist/react/media/focusViewer.css +31 -0
- package/dist/react/media/gifClip.d.ts +46 -0
- package/dist/react/media/gifClip.d.ts.map +1 -0
- package/dist/react/media/gifClip.js +74 -0
- package/dist/react/media/gifClip.js.map +1 -0
- package/dist/react/media/gifEncode.d.ts +46 -0
- package/dist/react/media/gifEncode.d.ts.map +1 -0
- package/dist/react/media/gifEncode.js +288 -0
- package/dist/react/media/gifEncode.js.map +1 -0
- package/dist/react/media/gifRecord.d.ts +26 -0
- package/dist/react/media/gifRecord.d.ts.map +1 -0
- package/dist/react/media/gifRecord.js +93 -0
- package/dist/react/media/gifRecord.js.map +1 -0
- package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
- package/dist/styles-areas/analytics.css +9 -9
- package/dist/styles-areas/data-table.css +10 -10
- package/dist/styles-areas/media.css +2 -2
- package/package.json +3 -3
- package/scripts/guardrailsEnforce.spec.ts +10 -0
- package/scripts/guardrailsEnforce.ts +4 -1
- package/scripts/styleAreas.ts +3 -1
- package/src/react/canvas-hud/CanvasToolbar.tsx +1 -1
- package/src/react/components/BuildFreshness.spec.tsx +10 -0
- package/src/react/components/BuildFreshness.tsx +13 -4
- package/src/react/components/DataTable/TableFilterBuilder.tsx +9 -13
- package/src/react/components/DataTable/tableSystem.spec.tsx +15 -7
- package/src/react/components/ListToolbar.tsx +2 -2
- package/src/react/hooks/useOutsidePress.spec.tsx +20 -0
- package/src/react/hooks/useOutsidePress.ts +17 -2
- package/src/react/hooks/useScrollMemory.spec.tsx +45 -2
- package/src/react/hooks/useScrollMemory.ts +20 -5
- package/src/react/media/FocusViewer.tsx +322 -25
- package/src/react/media/focusViewer.css +31 -0
- package/src/react/media/gifClip.ts +90 -0
- package/src/react/media/gifEncode.spec.ts +213 -0
- package/src/react/media/gifEncode.ts +298 -0
- package/src/react/media/gifRecord.ts +103 -0
- package/src/styles-areas/analytics.css +9 -9
- package/src/styles-areas/data-table.css +10 -10
- package/src/styles-areas/media.css +2 -2
|
@@ -62,6 +62,16 @@ describe('BuildFreshnessNotice', () => {
|
|
|
62
62
|
expect(screen.getByRole('button', { name: /Reload/i })).toBeDefined();
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
+
test('🔴 speaks off a CLIENT build stamp too — `/build.json`\'s top-level `{ commit }`', async () => {
|
|
66
|
+
// `forge-client stamp` writes the client's own commit at the top of `dist/build.json`. roms
|
|
67
|
+
// probes it (its `/healthz` is its Mac's), and a TV tab kept the old build for 84 minutes
|
|
68
|
+
// after a fast-lane ship because nothing read it twice (forge task 3022, 2026-10-10).
|
|
69
|
+
const stamp = (commit: string) => ({ commit, dirty: false });
|
|
70
|
+
healthzServing([stamp('a'.repeat(40)), stamp('b'.repeat(40))]);
|
|
71
|
+
render(<BuildFreshnessNotice intervalMs={5} probeUrl='/build.json' appLabel='ROMs' />);
|
|
72
|
+
await waitFor(() => expect(screen.queryByText(/ROMs was updated/i)).not.toBeNull(), { timeout: 2_000 });
|
|
73
|
+
});
|
|
74
|
+
|
|
65
75
|
test('stays silent when the probe fails — offline, or a 502 mid-deploy', async () => {
|
|
66
76
|
healthzServing([build('aaaaaaaaaaaa'), new TypeError('Failed to fetch')]);
|
|
67
77
|
render(<BuildFreshnessNotice intervalMs={5} />);
|
|
@@ -52,7 +52,8 @@ const MIN_GAP_MS = 30_000;
|
|
|
52
52
|
export interface UseBuildFreshnessOptions {
|
|
53
53
|
/**
|
|
54
54
|
* Where the serving build identifies itself. Any JSON answering
|
|
55
|
-
* `{ build: { commitFull } }` — every satellite's health route does
|
|
55
|
+
* `{ build: { commitFull } }` — every satellite's health route does — or a client build
|
|
56
|
+
* stamp's `{ commit }` (`/build.json`, `forge-client stamp`).
|
|
56
57
|
*/
|
|
57
58
|
probeUrl?: string;
|
|
58
59
|
/** Poll interval for a visible tab, in ms. */
|
|
@@ -70,11 +71,19 @@ export interface BuildFreshness {
|
|
|
70
71
|
servingCommit: string | null;
|
|
71
72
|
}
|
|
72
73
|
|
|
73
|
-
/**
|
|
74
|
+
/**
|
|
75
|
+
* The commit out of a health payload or a client build stamp, defensively.
|
|
76
|
+
*
|
|
77
|
+
* Two shapes, one identity: a satellite's `/healthz` nests it (`{ build: { commitFull } }`), and
|
|
78
|
+
* the CLIENT's own stamp — `forge-client stamp`'s `dist/build.json` (cursedops `stagedClient.ts`)
|
|
79
|
+
* — carries it at the top (`{ commit }`). The stamp is the better probe for an app whose health
|
|
80
|
+
* route answers for a server that ships on its own clock (roms' `/healthz` is its Mac's): it is a
|
|
81
|
+
* static file of the very build this tab loaded (forge 2026-10-10, task 3022).
|
|
82
|
+
*/
|
|
74
83
|
function commitOf(payload: unknown): string | null {
|
|
75
84
|
if (typeof payload !== 'object' || payload === null) return null;
|
|
76
|
-
const
|
|
77
|
-
|
|
85
|
+
const nested = (payload as { build?: unknown }).build;
|
|
86
|
+
const build = typeof nested === 'object' && nested !== null ? nested : payload;
|
|
78
87
|
const full = (build as { commitFull?: unknown }).commitFull;
|
|
79
88
|
const short = (build as { commit?: unknown }).commit;
|
|
80
89
|
const value = typeof full === 'string' && full ? full : typeof short === 'string' ? short : '';
|
|
@@ -3,8 +3,8 @@ import { useState } from 'react';
|
|
|
3
3
|
import { cn } from '../../lib/cn.js';
|
|
4
4
|
import { Button } from '../Button.js';
|
|
5
5
|
import { Input } from '../Input.js';
|
|
6
|
-
import { NativeSelect } from '../NativeSelect.js';
|
|
7
6
|
import { Popover } from '../Popover.js';
|
|
7
|
+
import { SelectField } from '../SelectField.js';
|
|
8
8
|
import {
|
|
9
9
|
countActiveConditions,
|
|
10
10
|
describeFilterQuery,
|
|
@@ -181,31 +181,27 @@ export function TableFilterBuilder({
|
|
|
181
181
|
<span className='w-12 shrink-0 text-right text-caption font-semibold uppercase tracking-wide text-muted-foreground'>
|
|
182
182
|
{conditionIndex === 0 ? 'Where' : 'and'}
|
|
183
183
|
</span>
|
|
184
|
-
<
|
|
184
|
+
<SelectField
|
|
185
185
|
size='sm'
|
|
186
186
|
aria-label='Column'
|
|
187
|
-
className='min-w-0 flex-1'
|
|
188
|
-
wrapperClassName='min-w-0 flex-1 max-sm:min-w-24'
|
|
187
|
+
className='min-w-0 flex-1 max-sm:min-w-24'
|
|
189
188
|
value={condition.column}
|
|
190
189
|
options={columns.map((c) => ({ value: c.key, label: c.label }))}
|
|
191
|
-
|
|
190
|
+
onValueChange={(column) =>
|
|
192
191
|
patchGroup(group.id, (list) =>
|
|
193
|
-
list.map((c) => (c.id === condition.id ? { ...c, column
|
|
192
|
+
list.map((c) => (c.id === condition.id ? { ...c, column } : c)),
|
|
194
193
|
)
|
|
195
194
|
}
|
|
196
195
|
/>
|
|
197
|
-
<
|
|
196
|
+
<SelectField
|
|
198
197
|
size='sm'
|
|
199
198
|
aria-label='Condition'
|
|
200
|
-
className='min-w-0'
|
|
201
|
-
wrapperClassName='w-40 shrink-0 max-sm:w-auto max-sm:min-w-32 max-sm:flex-1'
|
|
199
|
+
className='w-40 min-w-0 shrink-0 max-sm:w-auto max-sm:min-w-32 max-sm:flex-1'
|
|
202
200
|
value={condition.op}
|
|
203
201
|
options={OP_OPTIONS}
|
|
204
|
-
|
|
202
|
+
onValueChange={(op) =>
|
|
205
203
|
patchGroup(group.id, (list) =>
|
|
206
|
-
list.map((c) =>
|
|
207
|
-
c.id === condition.id ? { ...c, op: e.target.value as FilterOp } : c,
|
|
208
|
-
),
|
|
204
|
+
list.map((c) => (c.id === condition.id ? { ...c, op: op as FilterOp } : c)),
|
|
209
205
|
)
|
|
210
206
|
}
|
|
211
207
|
/>
|
|
@@ -238,6 +238,14 @@ describe('search AND the filter builder AND the sort stack compose', () => {
|
|
|
238
238
|
});
|
|
239
239
|
|
|
240
240
|
describe('the filter builder, driven the way a person drives it', () => {
|
|
241
|
+
/** Picks `label` in one of the builder's `SelectField`s as a keyboard does: open the trigger,
|
|
242
|
+
* then take the option. Its Column and Condition were OS menus until forge task 3063. */
|
|
243
|
+
const choose = (trigger: HTMLElement, label: string) => {
|
|
244
|
+
expect(trigger.getAttribute('role')).toBe('combobox');
|
|
245
|
+
fireEvent.keyDown(trigger, { key: 'Enter' });
|
|
246
|
+
fireEvent.click(screen.getByRole('option', { name: label }));
|
|
247
|
+
};
|
|
248
|
+
|
|
241
249
|
/**
|
|
242
250
|
* 🔴 The acceptance test for item 3 is the owner's own last clause — *"easy
|
|
243
251
|
* enough that I don't give up on trying it"* — so this walks the WHOLE
|
|
@@ -255,22 +263,22 @@ describe('the filter builder, driven the way a person drives it', () => {
|
|
|
255
263
|
// A condition with no value yet must NOT empty the table.
|
|
256
264
|
expect(bodyTitles()).toHaveLength(4);
|
|
257
265
|
|
|
258
|
-
const columnSelects = () => screen.getAllByLabelText('Column')
|
|
259
|
-
|
|
260
|
-
|
|
266
|
+
const columnSelects = () => screen.getAllByLabelText('Column');
|
|
267
|
+
choose(columnSelects()[0], 'Artist');
|
|
268
|
+
choose(screen.getAllByLabelText('Condition')[0], 'is');
|
|
261
269
|
fireEvent.change(screen.getAllByLabelText('Value')[0], { target: { value: 'alpha' } });
|
|
262
270
|
expect(bodyTitles()).toEqual(['bravo', 'delta']);
|
|
263
271
|
|
|
264
272
|
// AND: a second condition inside the same group.
|
|
265
273
|
fireEvent.click(screen.getByRole('button', { name: /^And$/ }));
|
|
266
|
-
|
|
274
|
+
choose(columnSelects()[1], 'Title');
|
|
267
275
|
fireEvent.change(screen.getAllByLabelText('Value')[1], { target: { value: 'bra' } });
|
|
268
276
|
expect(bodyTitles()).toEqual(['bravo']);
|
|
269
277
|
|
|
270
278
|
// OR: a second GROUP.
|
|
271
279
|
fireEvent.click(screen.getByRole('button', { name: /Or…/ }));
|
|
272
|
-
|
|
273
|
-
|
|
280
|
+
choose(columnSelects()[2], 'Artist');
|
|
281
|
+
choose(screen.getAllByLabelText('Condition')[2], 'is');
|
|
274
282
|
fireEvent.change(screen.getAllByLabelText('Value')[2], { target: { value: 'beta' } });
|
|
275
283
|
expect(bodyTitles()).toEqual(['alpha', 'bravo', 'charlie']);
|
|
276
284
|
|
|
@@ -297,7 +305,7 @@ describe('the filter builder, driven the way a person drives it', () => {
|
|
|
297
305
|
render(<DataTable columns={COLUMNS} data={TRACKS} getRowId={(r) => r.id} hidePagination />);
|
|
298
306
|
fireEvent.click(screen.getByRole('button', { name: /^Filter/ }));
|
|
299
307
|
fireEvent.click(screen.getByRole('button', { name: /Add a condition/ }));
|
|
300
|
-
|
|
308
|
+
choose(screen.getAllByLabelText('Condition')[0], 'is empty');
|
|
301
309
|
expect(screen.queryAllByLabelText('Value')).toHaveLength(0);
|
|
302
310
|
// Every fixture row has a title, so "title is empty" keeps nothing.
|
|
303
311
|
expect(bodyTitles()).toHaveLength(0);
|
|
@@ -34,7 +34,7 @@ export interface ListToolbarProps {
|
|
|
34
34
|
chipsAllLabel?: string;
|
|
35
35
|
/** Any further FILTER controls — they sit left, after the chips. */
|
|
36
36
|
filters?: ReactNode;
|
|
37
|
-
/** The sort control (a `
|
|
37
|
+
/** The sort control (a `SelectField`, a `ChoiceMenu`, …). Right-hand cluster. */
|
|
38
38
|
sort?: ReactNode;
|
|
39
39
|
/** The density/view-mode control — normally a `TableViewToggle`. Right-hand cluster. */
|
|
40
40
|
view?: ReactNode;
|
|
@@ -95,7 +95,7 @@ export interface ListToolbarProps {
|
|
|
95
95
|
* chips={buckets}
|
|
96
96
|
* chipValue={filter}
|
|
97
97
|
* onChipChange={(v) => setFilter(typeof v === 'string' ? v : null)}
|
|
98
|
-
* sort={<
|
|
98
|
+
* sort={<SelectField size='sm' aria-label='Sort the queue' … />}
|
|
99
99
|
* view={<TableViewToggle value={view.mode} onChange={view.setMode} />}
|
|
100
100
|
* />
|
|
101
101
|
*/
|
|
@@ -110,6 +110,26 @@ describe('useOutsidePress', () => {
|
|
|
110
110
|
expect(isOpen()).toBe(true);
|
|
111
111
|
});
|
|
112
112
|
|
|
113
|
+
/**
|
|
114
|
+
* 🔴 Forge task 3064: a Radix Select opens on the POINTERDOWN and makes the page inert, so the
|
|
115
|
+
* same press's pointerup — and the click it makes — land on `<html>`. Read as a press outside,
|
|
116
|
+
* that click shut the filter builder's popover the moment its Column picker opened (music's
|
|
117
|
+
* browser test, cursedbelt 5.77.1). A click is the end of the press that began it: one that
|
|
118
|
+
* began inside is inside, wherever the release fell.
|
|
119
|
+
*/
|
|
120
|
+
test('🔴 a press that begins inside and is released outside keeps it open', async () => {
|
|
121
|
+
render(<Panel />);
|
|
122
|
+
await press(screen.getByRole('button', { name: 'folders' }));
|
|
123
|
+
fireEvent.pointerDown(screen.getByRole('button', { name: 'open nested' }));
|
|
124
|
+
fireEvent.click(document.documentElement);
|
|
125
|
+
await act(() => new Promise((r) => setTimeout(r, 5)));
|
|
126
|
+
expect(isOpen()).toBe(true);
|
|
127
|
+
// The next press, begun outside, still closes it.
|
|
128
|
+
fireEvent.pointerDown(screen.getByRole('button', { name: 'row' }));
|
|
129
|
+
await press(screen.getByRole('button', { name: 'row' }));
|
|
130
|
+
expect(isOpen()).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
|
|
113
133
|
test('Escape closes it', async () => {
|
|
114
134
|
render(<Panel />);
|
|
115
135
|
await press(screen.getByRole('button', { name: 'folders' }));
|
|
@@ -125,8 +125,23 @@ export function useOutsidePress({
|
|
|
125
125
|
if (outside(target)) swallowUntil = Date.now() + 1000;
|
|
126
126
|
decideLater(target);
|
|
127
127
|
};
|
|
128
|
+
// `click` mode: where the press BEGAN. A Radix Select opens on the pointerdown and makes the
|
|
129
|
+
// page inert, so that same press's release — and the click it makes — land on `<html>`; read
|
|
130
|
+
// as outside, it shut the filter builder the moment its Column picker opened (forge task
|
|
131
|
+
// 3064). A click whose press began inside (in the DOM, or this panel's React tree — the
|
|
132
|
+
// panel's `onPointerDownCapture` runs after this capture listener) is inside.
|
|
133
|
+
let pressBegan: Node | null = null;
|
|
134
|
+
const onPressBegins = (event: PointerEvent) => {
|
|
135
|
+
pressInsideRef.current = false;
|
|
136
|
+
pressBegan = event.target instanceof Node ? event.target : null;
|
|
137
|
+
};
|
|
128
138
|
const onClick = (event: MouseEvent) => {
|
|
129
139
|
const target = event.target instanceof Node ? event.target : null;
|
|
140
|
+
if (decideOn === 'click') {
|
|
141
|
+
const began = pressBegan;
|
|
142
|
+
pressBegan = null;
|
|
143
|
+
if (began && (pressInsideRef.current || isInside(began) || onAnotherLayer(began))) return;
|
|
144
|
+
}
|
|
130
145
|
if (decideOn === 'pointerdown') {
|
|
131
146
|
if (Date.now() < swallowUntil) {
|
|
132
147
|
swallowUntil = 0;
|
|
@@ -149,11 +164,11 @@ export function useOutsidePress({
|
|
|
149
164
|
};
|
|
150
165
|
|
|
151
166
|
document.addEventListener('click', onClick, true);
|
|
152
|
-
|
|
167
|
+
document.addEventListener('pointerdown', decideOn === 'pointerdown' ? onPointerDown : onPressBegins, true);
|
|
153
168
|
if (closeOnEscape) document.addEventListener('keydown', onKeyDown);
|
|
154
169
|
return () => {
|
|
155
170
|
document.removeEventListener('click', onClick, true);
|
|
156
|
-
document.removeEventListener('pointerdown', onPointerDown, true);
|
|
171
|
+
document.removeEventListener('pointerdown', decideOn === 'pointerdown' ? onPointerDown : onPressBegins, true);
|
|
157
172
|
document.removeEventListener('keydown', onKeyDown);
|
|
158
173
|
for (const timer of timers) clearTimeout(timer);
|
|
159
174
|
};
|
|
@@ -41,8 +41,8 @@ function fakeScroller(height: number, viewport = 800) {
|
|
|
41
41
|
};
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
function Shell({ at, el, enabled }: { at: string; el: HTMLElement; enabled?: boolean }) {
|
|
45
|
-
useScrollMemory(at, { scroller: () => el, enabled });
|
|
44
|
+
function Shell({ at, el, enabled, deadlineMs }: { at: string; el: HTMLElement; enabled?: boolean; deadlineMs?: number }) {
|
|
45
|
+
useScrollMemory(at, { scroller: () => el, enabled, deadlineMs });
|
|
46
46
|
return null;
|
|
47
47
|
}
|
|
48
48
|
|
|
@@ -103,6 +103,49 @@ describe('useScrollMemory', () => {
|
|
|
103
103
|
expect(s.el.scrollTop).toBe(15_000);
|
|
104
104
|
});
|
|
105
105
|
|
|
106
|
+
/**
|
|
107
|
+
* 🔴 forge task 3069: flix's phone grid, reloaded, came back to 29,731px instead of 30,000 — its
|
|
108
|
+
* pages were still arriving one round trip at a time when the 3s deadline, counted from the
|
|
109
|
+
* START, ran out. A page that is still growing is still being followed.
|
|
110
|
+
*/
|
|
111
|
+
test('a list growing page by page is followed past the deadline, as long as each page lands within it', async () => {
|
|
112
|
+
const s = fakeScroller(20_000);
|
|
113
|
+
const { rerender } = render(<Shell at='/' el={s.el} deadlineMs={60} />);
|
|
114
|
+
s.scrollTo(15_000);
|
|
115
|
+
const list = { state: window.history.state, path: '/' };
|
|
116
|
+
push('/item/7');
|
|
117
|
+
rerender(<Shell at='/item/7' el={s.el} deadlineMs={60} />);
|
|
118
|
+
|
|
119
|
+
s.grow(3_000);
|
|
120
|
+
back(list);
|
|
121
|
+
rerender(<Shell at='/' el={s.el} deadlineMs={60} />);
|
|
122
|
+
// Five pages, 40ms apart: 200ms in all — well past 60ms from the start, never 60ms without a page.
|
|
123
|
+
for (const height of [6_000, 9_000, 12_000, 15_000, 20_000]) {
|
|
124
|
+
await act(() => Bun.sleep(40));
|
|
125
|
+
s.grow(height);
|
|
126
|
+
}
|
|
127
|
+
await frames(2);
|
|
128
|
+
expect(s.el.scrollTop).toBe(15_000);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('a list that STOPS growing is given up on once the deadline passes with no new page', async () => {
|
|
132
|
+
const s = fakeScroller(20_000);
|
|
133
|
+
const { rerender } = render(<Shell at='/' el={s.el} deadlineMs={60} />);
|
|
134
|
+
s.scrollTo(15_000);
|
|
135
|
+
const list = { state: window.history.state, path: '/' };
|
|
136
|
+
push('/item/7');
|
|
137
|
+
rerender(<Shell at='/item/7' el={s.el} deadlineMs={60} />);
|
|
138
|
+
|
|
139
|
+
s.grow(3_000);
|
|
140
|
+
back(list);
|
|
141
|
+
rerender(<Shell at='/' el={s.el} deadlineMs={60} />);
|
|
142
|
+
await act(() => Bun.sleep(150));
|
|
143
|
+
await frames(2);
|
|
144
|
+
s.grow(20_000); // too late: the restore settled where the page ended
|
|
145
|
+
await frames(3);
|
|
146
|
+
expect(s.el.scrollTop).toBe(2_200);
|
|
147
|
+
});
|
|
148
|
+
|
|
106
149
|
test('the viewer taking the page back cancels a restore that is still waiting', async () => {
|
|
107
150
|
const s = fakeScroller(20_000);
|
|
108
151
|
const { rerender } = render(<Shell at='/' el={s.el} />);
|
|
@@ -151,14 +151,17 @@ export interface ScrollMemoryOptions {
|
|
|
151
151
|
*/
|
|
152
152
|
enabled?: boolean;
|
|
153
153
|
/**
|
|
154
|
-
* How long a restore waits for the page to
|
|
155
|
-
*
|
|
156
|
-
*
|
|
154
|
+
* How long a restore waits for the page to GROW before settling as close as it got — counted
|
|
155
|
+
* from the last time it grew, so a list paging itself down keeps being followed (up to 20s in
|
|
156
|
+
* all). Default 3000ms — a reload, where only the first page of a list exists, needs a few
|
|
157
|
+
* pages' round trips, each of which must land within it.
|
|
157
158
|
*/
|
|
158
159
|
deadlineMs?: number;
|
|
159
160
|
}
|
|
160
161
|
|
|
161
162
|
const RESTORE_DEADLINE_MS = 3_000;
|
|
163
|
+
/** The longest a restore keeps following a page that is still growing — the viewer can always take it back sooner. */
|
|
164
|
+
const RESTORE_CEILING_MS = 20_000;
|
|
162
165
|
/**
|
|
163
166
|
* How long a landed restore holds its target against a scroll the viewer did not make. Long
|
|
164
167
|
* enough for a list to finish settling (the late move in forge task 2784 came inside 800ms, under
|
|
@@ -271,7 +274,14 @@ function restore(
|
|
|
271
274
|
deadlineMs: number,
|
|
272
275
|
settled: (outcome: RestoreOutcome) => void,
|
|
273
276
|
): () => void {
|
|
274
|
-
|
|
277
|
+
// 🔴 The deadline is for a page that has STOPPED growing, not a stopwatch from the start (forge
|
|
278
|
+
// task 3069): flix's phone grid, reloaded and coming back to 30,000px, pages itself down one round
|
|
279
|
+
// trip at a time, and with eight browsers sharing the Mac those trips took longer than 3s in all.
|
|
280
|
+
// The restore gave up at 29,731px while the pages were still arriving — a slow phone network is
|
|
281
|
+
// the same case. Each time the page grows the wait starts again, up to RESTORE_CEILING_MS.
|
|
282
|
+
const started = performance.now();
|
|
283
|
+
let deadline = started + deadlineMs;
|
|
284
|
+
let lastRoom = -1;
|
|
275
285
|
let frame = 0;
|
|
276
286
|
let holdTimer: ReturnType<typeof setTimeout> | undefined;
|
|
277
287
|
let phase: 'waiting' | 'holding' | 'done' = 'waiting';
|
|
@@ -306,7 +316,12 @@ function restore(
|
|
|
306
316
|
}
|
|
307
317
|
// Short of it: go as far as the page allows — that is what asks an infinite list for more.
|
|
308
318
|
scroller.set(Math.max(0, room));
|
|
309
|
-
|
|
319
|
+
const now = performance.now();
|
|
320
|
+
if (room > lastRoom) {
|
|
321
|
+
lastRoom = room;
|
|
322
|
+
deadline = Math.min(now + deadlineMs, started + Math.max(deadlineMs, RESTORE_CEILING_MS));
|
|
323
|
+
}
|
|
324
|
+
if (now >= deadline) {
|
|
310
325
|
finish();
|
|
311
326
|
settled('deadline');
|
|
312
327
|
} else frame = requestAnimationFrame(attempt);
|