@ultimat3/render 29.4.0 → 30.1.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/src/modes.ts CHANGED
@@ -33,8 +33,6 @@ export interface ModeSpec {
33
33
  readonly prerenderable: boolean;
34
34
  /** Does the mode need a revalidation trigger (tags or TTL)? */
35
35
  readonly needsRevalidate: boolean;
36
- /** Does the mode need at least one `<Suspense>` boundary to mean anything? */
37
- readonly needsSuspense: boolean;
38
36
  readonly description: string;
39
37
  }
40
38
 
@@ -46,7 +44,6 @@ export const MODE_SPECS = Object.freeze<Record<RenderMode, ModeSpec>>({
46
44
  perRequestState: false,
47
45
  prerenderable: true,
48
46
  needsRevalidate: false,
49
- needsSuspense: false,
50
47
  description: 'built once, served as a file',
51
48
  },
52
49
  isr: {
@@ -54,7 +51,6 @@ export const MODE_SPECS = Object.freeze<Record<RenderMode, ModeSpec>>({
54
51
  perRequestState: false,
55
52
  prerenderable: true,
56
53
  needsRevalidate: true,
57
- needsSuspense: false,
58
54
  description: 'static + background regen on tag/TTL',
59
55
  },
60
56
  ssr: {
@@ -62,7 +58,6 @@ export const MODE_SPECS = Object.freeze<Record<RenderMode, ModeSpec>>({
62
58
  perRequestState: true,
63
59
  prerenderable: false,
64
60
  needsRevalidate: false,
65
- needsSuspense: false,
66
61
  description: 'per-request full render',
67
62
  },
68
63
  stream: {
@@ -70,7 +65,6 @@ export const MODE_SPECS = Object.freeze<Record<RenderMode, ModeSpec>>({
70
65
  perRequestState: true,
71
66
  prerenderable: false,
72
67
  needsRevalidate: false,
73
- needsSuspense: true,
74
68
  description: 'static shell flushed instantly, holes streamed',
75
69
  },
76
70
  });
@@ -114,10 +108,10 @@ export function assertModeShape(config: RouteShape): void {
114
108
  }
115
109
 
116
110
  // isr: a CACHED document, so it may not be gated. The symmetry with `static` above is the
117
- // point — an ISR route resolves `load` with the request's own `Ctx`, renders that actor's
118
- // document, and the cache stores it under the pathname alone. Every later actor who passes the
119
- // same policy is then served the first actor's HTML. Keying the cache on more is a trap, not a
120
- // fix: the key would have to enumerate everything a policy and a `load` can read.
111
+ // point — both are rendered as NOBODY (`@ultimat3/http`'s `withAnonymousRequest`: no actor, no
112
+ // cookie, no `Authorization`), so `load` cannot put one visitor's answer in a stored page, and a
113
+ // policy would have no one to ask about. Keying the cache on more is a trap, not a fix: the key
114
+ // would have to enumerate everything a policy and a `load` can read. Per-visitor means `ssr`.
121
115
  if (config.render === 'isr' && config.policy !== undefined) {
122
116
  throw new RouteModeInvalidError(
123
117
  "render: 'isr' caches one document per URL and cannot be gated, but a `policy` was " +
@@ -182,8 +176,6 @@ export interface ModeCheckContext {
182
176
  readonly file: string;
183
177
  readonly path: string;
184
178
  readonly surface: RouteSurface;
185
- /** Counted from the route module's JSX by the build. `stream` needs at least one. */
186
- readonly suspenseBoundaries: number;
187
179
  }
188
180
 
189
181
  /** Checks that need the surrounding module and surface. Called by `registerRoute`. */
@@ -218,15 +210,6 @@ export function assertModeInvariants(config: RouteShape, ctx: ModeCheckContext):
218
210
  );
219
211
  }
220
212
 
221
- if (config.render === 'stream' && ctx.suspenseBoundaries < 1) {
222
- throw new RouteModeInvalidError(
223
- `${ctx.file} declares render: 'stream' but has no <Suspense> boundary, so there is ` +
224
- 'nothing to stream — the whole page waits like ssr',
225
- `wrap the data-dependent part of ${ctx.file} in <Suspense fallback={…}> ` +
226
- "or change render to 'ssr'",
227
- );
228
- }
229
-
230
213
  if (config.prerender !== undefined && !MODE_SPECS[config.render].prerenderable) {
231
214
  throw new RouteModeInvalidError(
232
215
  `${ctx.file} declares prerender with render: '${config.render}', which is not prerenderable`,
@@ -205,7 +205,7 @@ export function clearStylesheets(): void {
205
205
  * THE ORDER IS A FUNCTION OF THE SHEETS, NEVER OF THEIR ARRIVAL (22.3.5). A sheet registers in Bun's
206
206
  * `onLoad`, which runs as the loader fetches a module's dependencies — in parallel — and an island
207
207
  * build's too: the arrival order differed on every boot of one image, and so did the joined bytes
208
- * and the `/styles/<hash>.css` minted from them (notificado.co, 22.3.2–22.3.4: two pods, two URLs,
208
+ * and the `/styles/<hash>.css` minted from them (one adopter, 22.3.2–22.3.4: two pods, two URLs,
209
209
  * each the other's 404). `stylesheetOrder` is the whole rule:
210
210
  *
211
211
  * | key | first | why |
@@ -54,7 +54,7 @@ export function refresh(): Promise<void> {
54
54
  win.location.reload();
55
55
  return Promise.resolve();
56
56
  }
57
- return win.__xNavigation.refresh();
57
+ return win.__xNavigation.refresh().then(() => undefined);
58
58
  }
59
59
 
60
60
  /**
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The bound on a navigation's wait for its answer. A fetch has no timeout of its own, and one that
3
+ * never settles held the router's `inflight` forever: every later `refresh()` yielded to it.
4
+ */
5
+
6
+ /** Longest a navigation waits for its answer — a held prefetch's included — before giving up. */
7
+ export const NAVIGATION_TIMEOUT_MS = 30_000;
8
+
9
+ /** What the bound reads of the window: its timers, injected by the tests. */
10
+ type Timers = Pick<Window, 'setTimeout' | 'clearTimeout'>;
11
+
12
+ /**
13
+ * `ask(signal)`, raced against the deadline. `signal` aborts with `mine` (a newer navigation) or at
14
+ * the deadline, so the request is dropped either way; only a deadline REJECTS, with the abort's own
15
+ * reason, and `mine` stays un-aborted — the caller fails it like any network failure, never silently.
16
+ */
17
+ export function withinDeadline<T>(
18
+ win: Timers,
19
+ mine: AbortSignal,
20
+ ask: (signal: AbortSignal) => Promise<T>,
21
+ ): Promise<T> {
22
+ const timed = new AbortController();
23
+ let timer: number | undefined;
24
+ const expired = new Promise<never>((_, reject) => {
25
+ timer = win.setTimeout(() => {
26
+ timed.abort();
27
+ reject(timed.signal.reason);
28
+ }, NAVIGATION_TIMEOUT_MS);
29
+ });
30
+ // The race, not the signal alone: a held prefetch the click reuses was fetched with no signal.
31
+ // `ask` runs inside a promise so a synchronous throw rejects through the race, and its `finally`
32
+ // clears the timer — thrown before it, `expired` would reject later with nobody listening.
33
+ // (`Promise.try`, once the lib target types it.)
34
+ const answer = new Promise<T>((resolve) => resolve(ask(AbortSignal.any([mine, timed.signal]))));
35
+ return Promise.race([answer, expired]).finally(() => win.clearTimeout(timer));
36
+ }
@@ -169,6 +169,67 @@ export function focusMain(doc: Document): void {
169
169
  target.focus({ preventScroll: true });
170
170
  }
171
171
 
172
+ /**
173
+ * Where focus was, said in terms the NEXT document can answer: the control's `id`, else its `name`
174
+ * in the same form, else its row (`[data-row]`) with its tag and place in that row. `null` when
175
+ * nothing had focus — the body, or no element at all.
176
+ */
177
+ export interface FocusMark {
178
+ readonly id: string;
179
+ readonly name: string | null;
180
+ /** The form's `id`, else its place among the document's forms; `null` outside a form. */
181
+ readonly form: string | number | null;
182
+ readonly row: string | null;
183
+ readonly tag: string;
184
+ readonly nth: number;
185
+ }
186
+
187
+ export function markFocus(doc: Document): FocusMark | null {
188
+ const el = doc.activeElement;
189
+ if (el === null || el === doc.body || el === doc.documentElement) return null;
190
+ const form = el.closest('form');
191
+ const rowEl = el.closest('[data-row]');
192
+ const tag = el.tagName.toLowerCase();
193
+ return {
194
+ id: el.id,
195
+ name: el.getAttribute('name'),
196
+ form: form === null ? null : form.id || [...doc.querySelectorAll('form')].indexOf(form),
197
+ row: rowEl?.getAttribute('data-row') ?? null,
198
+ tag,
199
+ nth: rowEl === null ? -1 : [...rowEl.querySelectorAll(tag)].indexOf(el),
200
+ };
201
+ }
202
+
203
+ /** Focus the control `mark` names in the document now on screen, else `<main>`. No mark: nothing. */
204
+ export function restoreFocus(doc: Document, mark: FocusMark | null): void {
205
+ if (mark === null) return;
206
+ const target = markedIn(doc, mark);
207
+ if (target === null) focusMain(doc);
208
+ else target.focus({ preventScroll: true });
209
+ }
210
+
211
+ function markedIn(doc: Document, mark: FocusMark): HTMLElement | null {
212
+ if (mark.id !== '') return doc.getElementById(mark.id);
213
+ // Attribute VALUES compared as strings, never spliced into a selector: a name needs no escaping.
214
+ const by = (scope: ParentNode, attr: string, value: string): HTMLElement | null =>
215
+ [...scope.querySelectorAll<HTMLElement>(`[${attr}]`)].find(
216
+ (el) => el.getAttribute(attr) === value,
217
+ ) ?? null;
218
+ if (mark.name !== null) {
219
+ const forms = [...doc.querySelectorAll('form')];
220
+ const form =
221
+ mark.form === null
222
+ ? doc
223
+ : typeof mark.form === 'number'
224
+ ? forms[mark.form]
225
+ : forms.find((f) => f.id === mark.form);
226
+ return form === undefined ? null : by(form, 'name', mark.name);
227
+ }
228
+ if (mark.row === null) return null;
229
+ const row = by(doc, 'data-row', mark.row);
230
+ return row?.querySelectorAll<HTMLElement>(mark.tag)[mark.nth] ?? null;
231
+ }
232
+
172
233
  /** A polite live region carried from body to body; styled through the CSSOM, which CSP allows. */
173
234
  export function announcer(doc: Document): HTMLElement {
174
235
  const region = doc.createElement('div');
@@ -0,0 +1,74 @@
1
+ /**
2
+ * What makes a `refresh()` a refresh: the same page for the same visitor. Its swap runs without a
3
+ * view transition, leaves focus on the control the visitor was in and announces nothing; a
4
+ * navigation already in flight is never cancelled for it. `navigation.ts` asks these at each step.
5
+ */
6
+
7
+ import { type FocusMark, focusMain, markFocus, restoreFocus, transition } from './navigation-dom';
8
+
9
+ /**
10
+ * One `refresh()` in flight: `held` is asked again at the swap — the visitor may have started
11
+ * typing or opened a dialog while the page was fetched — and `settled` is what `refresh()` answers:
12
+ * `true` once it landed OR failed (neither is owed again), `false` when it was skipped.
13
+ */
14
+ export interface RefreshRun {
15
+ readonly held: (() => boolean) | undefined;
16
+ settled: boolean;
17
+ }
18
+
19
+ /** Held across a refresh's swap: where focus was. `undefined` for an ordinary navigation. */
20
+ export type RefreshHold = { readonly mark: FocusMark | null; readonly run: RefreshRun } | undefined;
21
+
22
+ /**
23
+ * Taken at the swap, while the focused control is still in the document. `null`: the refresh is
24
+ * held NOW, so nothing is swapped and `refresh()` answers `false` for its caller to owe it again.
25
+ */
26
+ export function holdFor(doc: Document, run: RefreshRun | undefined): RefreshHold | null {
27
+ if (run === undefined) return undefined;
28
+ return run.held?.() === true ? null : { mark: markFocus(doc), run };
29
+ }
30
+
31
+ /**
32
+ * Whether this navigation is skipped: a refresh while another navigation is in flight. That one
33
+ * fetches fresh anyway, and aborting it is how a visitor's click was lost.
34
+ */
35
+ export function refreshYields(refresh: RefreshRun | undefined, inflight: unknown): boolean {
36
+ return refresh !== undefined && inflight !== undefined;
37
+ }
38
+
39
+ /** The swap: a view transition for a new place, a plain call for the same page. */
40
+ export function swapIn(
41
+ win: Window,
42
+ hold: RefreshHold,
43
+ apply: () => void,
44
+ track: Parameters<typeof transition>[2],
45
+ ): Promise<void> {
46
+ if (hold === undefined) return transition(win, apply, track);
47
+ apply();
48
+ return Promise.resolve();
49
+ }
50
+
51
+ /**
52
+ * Whether a navigation whose answer never came (the network, `NAVIGATION_TIMEOUT_MS`) ends here,
53
+ * quietly: a refresh is. Reloading a page under a reader is what a refresh exists to prevent, and
54
+ * the page on screen is still the visitor's; settled, so the scheduler does not owe it again.
55
+ */
56
+ export function refreshFailsQuietly(run: RefreshRun | undefined): boolean {
57
+ if (run === undefined) return false;
58
+ run.settled = true;
59
+ return true;
60
+ }
61
+
62
+ /**
63
+ * After the scripts ran, so an island's mounted control is there to take focus back. A new place:
64
+ * `<main>` and the title read out. A refresh: the marked control, and the announcer untouched.
65
+ */
66
+ export function landFocus(doc: Document, live: HTMLElement, hold: RefreshHold): void {
67
+ if (hold !== undefined) {
68
+ restoreFocus(doc, hold.mark);
69
+ hold.run.settled = true;
70
+ return;
71
+ }
72
+ focusMain(doc);
73
+ live.textContent = doc.title;
74
+ }
package/src/navigation.ts CHANGED
@@ -16,16 +16,8 @@
16
16
  */
17
17
 
18
18
  import { navigationCache } from './navigation-cache';
19
- import {
20
- anchorOf,
21
- announcer,
22
- focusMain,
23
- handOver,
24
- intentUrl,
25
- linkFacts,
26
- submitFacts,
27
- transition,
28
- } from './navigation-dom';
19
+ import { withinDeadline } from './navigation-deadline';
20
+ import { anchorOf, announcer, handOver, intentUrl, linkFacts, submitFacts } from './navigation-dom';
29
21
  import {
30
22
  type Answer,
31
23
  fetchDocument,
@@ -43,6 +35,14 @@ import {
43
35
  import { modalController } from './navigation-modal';
44
36
  import { addressOf, modalAddress, modalHistory } from './navigation-modal-rules';
45
37
  import { pressTracker } from './navigation-press';
38
+ import type { RefreshRun } from './navigation-refresh';
39
+ import {
40
+ holdFor,
41
+ landFocus,
42
+ refreshFailsQuietly,
43
+ refreshYields,
44
+ swapIn,
45
+ } from './navigation-refresh';
46
46
  import {
47
47
  answerMovesTab,
48
48
  formVerdict,
@@ -77,13 +77,19 @@ export interface NavigateOptions {
77
77
  readonly hops?: number;
78
78
  /** The URL's hash addressed it: a modal, or nothing — never a page (`presentation`). */
79
79
  readonly fromHash?: boolean;
80
+ /** The page on screen again, for the same visitor (`navigation-refresh.ts`). */
81
+ readonly refresh?: RefreshRun;
80
82
  }
81
83
 
82
84
  export interface NavigationRouter {
83
85
  navigate(url: string, options?: NavigateOptions): Promise<void>;
84
86
  prefetch(url: string): void;
85
- /** The page on screen, fetched again and swapped in where it is scrolled. */
86
- refresh(): Promise<void>;
87
+ /**
88
+ * The page fetched again and swapped in where it is scrolled, unless `held()` says the visitor
89
+ * is busy. `true` once SETTLED (`RefreshRun`): landed, or failed quietly (`refreshFailsQuietly`).
90
+ * `false` when held, yielded to another navigation, or a modal was open.
91
+ */
92
+ refresh(held?: () => boolean): Promise<boolean>;
87
93
  /** `path` as the modal over this page (`#<path>`); anything but a modal of this tab: nothing. */
88
94
  openModal(path: string): Promise<void>;
89
95
  /** As Escape: Back through the router's own entry, else the hash dropped in place. */
@@ -158,21 +164,19 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
158
164
  else win.location.reload();
159
165
  };
160
166
 
161
- const answerFor = (
162
- url: string,
163
- method: 'GET' | 'POST',
164
- options: NavigateOptions,
165
- signal: AbortSignal,
166
- ) => {
167
- if (method === 'POST') {
168
- // Before AND once it settles, landed or not: a guess sent while the write was in flight
169
- // may have rendered the old state, and a write that failed here may still have committed.
170
- forget();
171
- const body = options.body === undefined ? {} : { body: options.body };
172
- return request(url, 'soft', { method, ...body, signal }).finally(forget);
173
- }
174
- return heldOrFetched(cache, url, () => request(url, 'soft', { signal }));
175
- };
167
+ /** Bounded by `NAVIGATION_TIMEOUT_MS`: a hung answer never holds `inflight` for good. */
168
+ const answerFor = (url: string, options: NavigateOptions, mine: AbortSignal) =>
169
+ withinDeadline(win, mine, (signal) => {
170
+ const method = options.method ?? 'GET';
171
+ if (method === 'POST') {
172
+ // Before AND once it settles, landed or not: a guess sent while the write was in flight
173
+ // may have rendered the old state, and a write that failed here may still have committed.
174
+ forget();
175
+ const body = options.body === undefined ? {} : { body: options.body };
176
+ return request(url, 'soft', { method, ...body, signal }).finally(forget);
177
+ }
178
+ return heldOrFetched(cache, url, () => request(url, 'soft', { signal }));
179
+ });
176
180
 
177
181
  const navigate = async (url: string, options: NavigateOptions = {}): Promise<void> => {
178
182
  // The redirect chain's only exit is `hops >= NAVIGATION_MAX_HOPS`, which `NaN` never meets.
@@ -189,6 +193,7 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
189
193
  else win.location.assign(url);
190
194
  return;
191
195
  }
196
+ if (refreshYields(options.refresh, inflight)) return;
192
197
  const detail = { url, method };
193
198
  if (!doc.dispatchEvent(new CustomEvent(NAVIGATE_EVENT, { detail, cancelable: true }))) return;
194
199
  inflight?.abort();
@@ -207,9 +212,9 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
207
212
  try {
208
213
  let answer: Answer;
209
214
  try {
210
- answer = await answerFor(url, method, options, mine.signal);
215
+ answer = await answerFor(url, options, mine.signal);
211
216
  } catch {
212
- if (mine.signal.aborted) return;
217
+ if (mine.signal.aborted || refreshFailsQuietly(options.refresh)) return;
213
218
  // A GET that never arrived is the browser's to try; a POST that failed is never re-sent.
214
219
  if (method === 'GET') win.location.assign(url);
215
220
  else failed(url, method, 'the request failed on the network');
@@ -280,14 +285,17 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
280
285
  modal.release();
281
286
  modal.dismiss();
282
287
  let swapped: ReturnType<typeof swapDocument> | undefined;
283
- await transition(
288
+ const hold = holdFor(doc, options.refresh);
289
+ if (hold === null) return;
290
+ await swapIn(
284
291
  win,
292
+ hold,
285
293
  () => {
286
294
  // A newer navigation started while the sheets loaded: this answer is no longer asked for.
287
295
  if (mine.signal.aborted) return;
288
296
  swapped = swapDocument(doc, next, owned);
289
297
  // Emptied here and filled after the scripts, so a page with the same title still reads out.
290
- live.textContent = '';
298
+ if (hold === undefined) live.textContent = '';
291
299
  swapped.body.append(live);
292
300
  rendered = withoutFragment(landed);
293
301
  if (history !== 'none') {
@@ -314,8 +322,7 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
314
322
  await runScripts([...done.body.querySelectorAll('script')], ran, (fresh, inert) =>
315
323
  inert.replaceWith(fresh),
316
324
  );
317
- focusMain(doc);
318
- live.textContent = doc.title;
325
+ landFocus(doc, live, hold);
319
326
  doc.dispatchEvent(new CustomEvent(NAVIGATED_EVENT, { detail: { url: landed } }));
320
327
  reconcile();
321
328
  } catch {
@@ -458,11 +465,15 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
458
465
  const router: NavigationRouter = {
459
466
  navigate,
460
467
  prefetch,
461
- refresh() {
462
- // A fresh answer for the page on screen, landing where the visitor is scrolled.
468
+ async refresh(held) {
469
+ // A fresh answer for the page on screen, landing where the visitor is scrolled. Not under
470
+ // an open modal: the page beneath is out of reach, and the swap would take the modal with it.
471
+ if (modal.address !== undefined) return false;
463
472
  cache.delete(rendered);
464
473
  scroll.save();
465
- return navigate(rendered, { history: 'none' });
474
+ const run: RefreshRun = { held, settled: false };
475
+ await navigate(rendered, { history: 'none', refresh: run });
476
+ return run.settled;
466
477
  },
467
478
  openModal(path) {
468
479
  const address = modalAddress(`#${path}`, win.location.href);
@@ -0,0 +1,171 @@
1
+ /**
2
+ * `scheduleRefresh()`: "the server's page changed" said by a channel, paid when the visitor is not
3
+ * in the middle of something. Coalesced to one running and one owed; held while the tab is hidden,
4
+ * a dialog or popover is open, a field has focus, text is selected, a navigation runs, or within
5
+ * `QUIET_MS` of input. State lives on `window` under one `Symbol.for` key: one per page, any chunk.
6
+ */
7
+
8
+ import { type Clock, sessionIsLost, systemClock } from '@ultimat3/core/page';
9
+ import type { NavigationRouter } from './navigation';
10
+
11
+ /** How long after a scroll, wheel, key or press the page counts as being read. */
12
+ export const QUIET_MS = 1_000;
13
+
14
+ /** A press unreleased this long is a lost `pointerup`, not a visitor still pressing. */
15
+ export const PRESS_HOLD_MS = 10_000;
16
+
17
+ /**
18
+ * A refresh not settled by now is given up on, so one hung answer cannot hold `running` forever and
19
+ * starve every later ask. Long past any answer still worth swapping in under a reader.
20
+ */
21
+ export const REFRESH_TIMEOUT_MS = 30_000;
22
+
23
+ const KEY: unique symbol = Symbol.for('ultimate.refresh-idle');
24
+
25
+ /** What the scheduler reads of the window; injected by the tests. */
26
+ export interface IdleWindow {
27
+ readonly document: Document;
28
+ readonly __xNavigation?: Pick<NavigationRouter, 'refresh'> | undefined;
29
+ addEventListener(type: string, fn: () => void, options?: AddEventListenerOptions): void;
30
+ getSelection(): { readonly isCollapsed: boolean } | null;
31
+ setTimeout(fn: () => void, ms: number): number;
32
+ clearTimeout(id: number | undefined): void;
33
+ }
34
+
35
+ interface IdleState {
36
+ running: boolean;
37
+ owed: boolean;
38
+ timer: number | undefined;
39
+ lastInput: number;
40
+ pressedAt: number | undefined;
41
+ readonly schedule: () => void;
42
+ }
43
+
44
+ type StateHost = { [KEY]?: IdleState };
45
+
46
+ /**
47
+ * Ask for the page on screen to be re-read once the visitor is idle. Never a reload: without the
48
+ * client router this does nothing, because reloading a page under a reader is the failure it
49
+ * exists to prevent. After a lost session (`sessionIsLost()`), nothing, ever.
50
+ */
51
+ export function scheduleRefresh(): void {
52
+ const win = (globalThis as { window?: IdleWindow }).window;
53
+ if (win !== undefined) refreshIdleOn(win, systemClock)();
54
+ }
55
+
56
+ /** The page's one scheduler over `win`, created on first use. Internal: tests inject `win`/`clock`. */
57
+ export function refreshIdleOn(win: IdleWindow, clock: Pick<Clock, 'monotonic'>): () => void {
58
+ const host = win as StateHost;
59
+ const existing = host[KEY];
60
+ if (existing !== undefined) return existing.schedule;
61
+ if (win.__xNavigation === undefined) return (): void => {};
62
+ const doc = win.document;
63
+ const state: IdleState = {
64
+ running: false,
65
+ owed: false,
66
+ timer: undefined,
67
+ // Far enough back that a page nobody touched is quiet from the start.
68
+ lastInput: clock.monotonic() - QUIET_MS,
69
+ pressedAt: undefined,
70
+ schedule: (): void => {
71
+ if (sessionIsLost()) return;
72
+ state.owed = true;
73
+ pump();
74
+ },
75
+ };
76
+
77
+ /** Milliseconds until it is worth asking again; `0` = free now; `Infinity` = wait for an event. */
78
+ const heldFor = (): number => {
79
+ if (doc.visibilityState === 'hidden') return Number.POSITIVE_INFINITY;
80
+ const now = clock.monotonic();
81
+ if (state.pressedAt !== undefined && now - state.pressedAt < PRESS_HOLD_MS) {
82
+ return Math.min(QUIET_MS, PRESS_HOLD_MS - (now - state.pressedAt));
83
+ }
84
+ const quiet = QUIET_MS - (now - state.lastInput);
85
+ if (quiet > 0) return quiet;
86
+ return busy() ? QUIET_MS : 0;
87
+ };
88
+
89
+ const busy = (): boolean => {
90
+ if (doc.documentElement.hasAttribute('data-x-navigating')) return true;
91
+ if (doc.querySelector('dialog[open]') !== null || popoverOpen()) return true;
92
+ const active = doc.activeElement as (Element & { isContentEditable?: boolean }) | null;
93
+ if (active !== null && active !== doc.body) {
94
+ if (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA') return true;
95
+ if (active.isContentEditable === true) return true;
96
+ }
97
+ return win.getSelection()?.isCollapsed === false;
98
+ };
99
+
100
+ const popoverOpen = (): boolean => {
101
+ try {
102
+ return doc.querySelector('[popover]:popover-open') !== null;
103
+ } catch {
104
+ // A browser without the Popover API cannot parse the pseudo-class, and has no open popover.
105
+ return false;
106
+ }
107
+ };
108
+
109
+ const pump = (): void => {
110
+ if (!state.owed || state.running || sessionIsLost()) return;
111
+ win.clearTimeout(state.timer);
112
+ state.timer = undefined;
113
+ const wait = heldFor();
114
+ if (wait === Number.POSITIVE_INFINITY) return;
115
+ if (wait > 0) {
116
+ state.timer = win.setTimeout(pump, wait);
117
+ return;
118
+ }
119
+ state.owed = false;
120
+ state.running = true;
121
+ const router = win.__xNavigation;
122
+ let settled = false;
123
+ let deadline: number | undefined;
124
+ const done = (ran: boolean): void => {
125
+ // Once per run: a hung answer landing after its timeout belongs to no run.
126
+ if (settled) return;
127
+ settled = true;
128
+ win.clearTimeout(deadline);
129
+ state.running = false;
130
+ if (ran) {
131
+ pump();
132
+ return;
133
+ }
134
+ // Skipped — a navigation in flight, an open modal, the visitor busy when the answer landed:
135
+ // still owed, and asked again after a quiet second rather than spun on at once.
136
+ state.owed = true;
137
+ state.timer = win.setTimeout(pump, QUIET_MS);
138
+ };
139
+ if (router === undefined) {
140
+ done(true);
141
+ return;
142
+ }
143
+ // Given up on, a hung refresh counts as run: retrying it would only ask the same stuck router.
144
+ deadline = win.setTimeout(() => done(true), REFRESH_TIMEOUT_MS);
145
+ // A failed refresh is not retried here: the router already handed the visitor its own answer.
146
+ router.refresh(() => heldFor() > 0).then(done, () => done(true));
147
+ };
148
+
149
+ const input = (): void => {
150
+ state.lastInput = clock.monotonic();
151
+ };
152
+ const passive = { passive: true, capture: true };
153
+ for (const type of ['scroll', 'wheel', 'keydown']) win.addEventListener(type, input, passive);
154
+ win.addEventListener(
155
+ 'pointerdown',
156
+ () => {
157
+ input();
158
+ state.pressedAt = clock.monotonic();
159
+ },
160
+ passive,
161
+ );
162
+ const release = (): void => {
163
+ input();
164
+ state.pressedAt = undefined;
165
+ };
166
+ win.addEventListener('pointerup', release, passive);
167
+ win.addEventListener('pointercancel', release, passive);
168
+ doc.addEventListener('visibilitychange', pump);
169
+ Object.defineProperty(host, KEY, { value: state, configurable: true });
170
+ return state.schedule;
171
+ }
package/src/registry.ts CHANGED
@@ -6,7 +6,6 @@
6
6
  */
7
7
 
8
8
  import type { HydrateStrategy, OfflineStrategy, RenderMode } from '@ultimat3/core';
9
- import { finiteCount } from '@ultimat3/core';
10
9
  import { byCodeUnit } from './code-unit-order';
11
10
  import {
12
11
  RouteDuplicateError,
@@ -50,7 +49,6 @@ export interface RouteEntry<TData = RouteData> {
50
49
  readonly path: string;
51
50
  readonly surface: RouteSurface;
52
51
  readonly config: RouteConfig<TData>;
53
- readonly suspenseBoundaries: number;
54
52
  readonly islands: readonly string[];
55
53
  readonly pattern: CompiledPattern;
56
54
  /**
@@ -76,7 +74,7 @@ export interface RouteDescriptor {
76
74
  readonly revalidateMaxStale?: string | number | null;
77
75
  /**
78
76
  * `revalidate.query`, sorted: the only query parameters an `isr` page of this route keys on.
79
- * `null` when the route declared none — its pages then key on the WHOLE query string.
77
+ * `null` when the route declared none — its pages then key on no query at all.
80
78
  */
81
79
  readonly revalidateQuery?: readonly string[] | null;
82
80
  readonly prerenderable: boolean;
@@ -234,8 +232,6 @@ let described: readonly RouteDescriptor[] | undefined;
234
232
  export interface RegisterRouteInput<TData = RouteData> {
235
233
  readonly file: string;
236
234
  readonly config: RouteConfig<TData>;
237
- /** Counted from the module's JSX by the build; `stream` requires >= 1. */
238
- readonly suspenseBoundaries?: number;
239
235
  // No `islands` key: an island is declared by `island()` and reaches the entry through
240
236
  // `config.islands`. It was here, undocumented, passed by nothing, and read as
241
237
  // `input.islands ?? []` — so the only thing a caller could do with it was un-weigh a
@@ -263,14 +259,6 @@ export function registerRoute<TData = RouteData>(
263
259
 
264
260
  const derived = routePathFromFile(input.file);
265
261
  const path = input.path ?? derived.path;
266
- // `ctx.suspenseBoundaries < 1` is the only thing between `render: 'stream'` and a route that
267
- // streams nothing, and `NaN < 1` is false — a count that arrived non-finite does not report the
268
- // route it counted, it stops reporting any route.
269
- const suspenseBoundaries = finiteCount(
270
- 'registerRoute',
271
- 'suspenseBoundaries',
272
- input.suspenseBoundaries ?? 0,
273
- );
274
262
  // Explicit `<TData>`: `isRouteConfig` is a guard over the default `RouteData`, so inference off
275
263
  // the narrowed argument would resolve the route's own data generic away here.
276
264
  const config = withIslandBudget<TData>(input.config, derived.surface);
@@ -279,7 +267,6 @@ export function registerRoute<TData = RouteData>(
279
267
  file: input.file,
280
268
  path,
281
269
  surface: derived.surface,
282
- suspenseBoundaries,
283
270
  });
284
271
 
285
272
  assertPurgeableTags(input.file, config.revalidate?.tags);
@@ -301,7 +288,6 @@ export function registerRoute<TData = RouteData>(
301
288
  path,
302
289
  surface: derived.surface,
303
290
  config,
304
- suspenseBoundaries,
305
291
  // The declaration is the ONLY source. It was `input.islands ?? []`, which nothing ever passed,
306
292
  // so the now-deleted `routeJsBytes`'s "what registration declared" half read `[]` on every
307
293
  // route in the framework's history — and keeping the input as a fallback would be a second