@ultimat3/render 25.2.0 → 26.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/CLAUDE.md +2 -2
- package/README.md +29 -5
- package/package.json +6 -6
- package/src/client-sync-tags.ts +1 -1
- package/src/errors.ts +19 -3
- package/src/index.ts +16 -0
- package/src/modes.ts +2 -9
- package/src/navigation-api.ts +84 -0
- package/src/navigation-dom.ts +54 -2
- package/src/navigation-fetch.ts +52 -1
- package/src/navigation-history.ts +8 -1
- package/src/navigation-modal-rules.ts +110 -0
- package/src/navigation-modal.ts +233 -0
- package/src/navigation-press.ts +59 -0
- package/src/navigation-swap.ts +9 -2
- package/src/navigation-tags.ts +16 -3
- package/src/navigation.ts +137 -143
- package/src/registry.ts +37 -31
- package/src/route.ts +11 -3
- package/src/surfaces.ts +6 -0
package/src/navigation.ts
CHANGED
|
@@ -10,25 +10,29 @@
|
|
|
10
10
|
* over instead of letting `fetch` follow it, and a POST is never re-submitted — after a failed
|
|
11
11
|
* one, only the server knows what landed.
|
|
12
12
|
*
|
|
13
|
-
* Loaded as one deferred classic script (`@ultimat3/cli` builds it to
|
|
14
|
-
* on documents whose surface opted in (`navigation: { client:
|
|
13
|
+
* Loaded as one deferred classic script (`@ultimat3/cli` builds it to
|
|
14
|
+
* `/_x/assets/navigation/<hash>.js`) on documents whose surface opted in (`navigation: { client:
|
|
15
|
+
* [...] }` in `app.config.ts`).
|
|
15
16
|
*/
|
|
16
17
|
|
|
17
|
-
import { CLIENT_BUILD_META, CLIENT_SCOPE_META } from '@ultimat3/core/page';
|
|
18
18
|
import { navigationCache } from './navigation-cache';
|
|
19
19
|
import {
|
|
20
20
|
anchorOf,
|
|
21
21
|
announcer,
|
|
22
22
|
focusMain,
|
|
23
|
-
formAction,
|
|
24
|
-
formFields,
|
|
25
|
-
formPairs,
|
|
26
23
|
handOver,
|
|
24
|
+
intentUrl,
|
|
27
25
|
linkFacts,
|
|
28
|
-
|
|
26
|
+
submitFacts,
|
|
29
27
|
transition,
|
|
30
28
|
} from './navigation-dom';
|
|
31
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
type Answer,
|
|
31
|
+
fetchDocument,
|
|
32
|
+
heldOrFetched,
|
|
33
|
+
metaOf,
|
|
34
|
+
responseFactsOf,
|
|
35
|
+
} from './navigation-fetch';
|
|
32
36
|
import {
|
|
33
37
|
type EntryState,
|
|
34
38
|
entryOf,
|
|
@@ -36,25 +40,22 @@ import {
|
|
|
36
40
|
scrollAfter,
|
|
37
41
|
withoutFragment,
|
|
38
42
|
} from './navigation-history';
|
|
43
|
+
import { modalController } from './navigation-modal';
|
|
44
|
+
import { addressOf, modalAddress, modalHistory } from './navigation-modal-rules';
|
|
45
|
+
import { pressTracker } from './navigation-press';
|
|
39
46
|
import {
|
|
40
47
|
answerMovesTab,
|
|
41
|
-
type FormFacts,
|
|
42
48
|
formVerdict,
|
|
43
49
|
linkVerdict,
|
|
44
|
-
mayPrefetch,
|
|
45
50
|
NAVIGATE_EVENT,
|
|
46
51
|
NAVIGATED_EVENT,
|
|
47
52
|
NAVIGATING_ATTRIBUTE,
|
|
48
53
|
NAVIGATION_ERROR_EVENT,
|
|
49
54
|
NAVIGATION_MAX_HOPS,
|
|
50
55
|
NAVIGATION_META,
|
|
51
|
-
NAVIGATION_NO_PREFETCH_ATTRIBUTE,
|
|
52
56
|
NAVIGATION_PREFETCH_DELAY_MS,
|
|
53
57
|
NAVIGATION_PROGRESS_DELAY_MS,
|
|
54
|
-
NAVIGATION_RELOAD_ATTRIBUTE,
|
|
55
|
-
type ResponseFacts,
|
|
56
58
|
responseVerdict,
|
|
57
|
-
reusable,
|
|
58
59
|
} from './navigation-rules';
|
|
59
60
|
import { scrollKeeper } from './navigation-scroll';
|
|
60
61
|
import {
|
|
@@ -74,11 +75,19 @@ export interface NavigateOptions {
|
|
|
74
75
|
readonly history?: 'push' | 'replace' | 'none';
|
|
75
76
|
/** Redirects already followed for this navigation. */
|
|
76
77
|
readonly hops?: number;
|
|
78
|
+
/** The URL's hash addressed it: a modal, or nothing — never a page (`presentation`). */
|
|
79
|
+
readonly fromHash?: boolean;
|
|
77
80
|
}
|
|
78
81
|
|
|
79
82
|
export interface NavigationRouter {
|
|
80
83
|
navigate(url: string, options?: NavigateOptions): Promise<void>;
|
|
81
84
|
prefetch(url: string): void;
|
|
85
|
+
/** The page on screen, fetched again and swapped in where it is scrolled. */
|
|
86
|
+
refresh(): Promise<void>;
|
|
87
|
+
/** `path` as the modal over this page (`#<path>`); anything but a modal of this tab: nothing. */
|
|
88
|
+
openModal(path: string): Promise<void>;
|
|
89
|
+
/** As Escape: Back through the router's own entry, else the hash dropped in place. */
|
|
90
|
+
closeModal(): void;
|
|
82
91
|
stop(): void;
|
|
83
92
|
}
|
|
84
93
|
|
|
@@ -104,15 +113,11 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
104
113
|
let rendered = withoutFragment(win.location.href);
|
|
105
114
|
/** The URL a navigation in flight is fetching: a prefetch never asks for it a second time. */
|
|
106
115
|
let navigatingTo: string | undefined;
|
|
107
|
-
|
|
108
|
-
let animating: RunningTransition | undefined;
|
|
109
|
-
/** Between a press and its click: the focus the press gives a link is not a hover. */
|
|
110
|
-
let pressed = false;
|
|
111
|
-
/** The press landed while a transition was painting over the page (see `onClick`). */
|
|
112
|
-
let pressedOverTransition = false;
|
|
116
|
+
const press = pressTracker(doc);
|
|
113
117
|
/** The pending prefetch of a hover, cancelled by anything that navigates. */
|
|
114
118
|
let intent: number | undefined;
|
|
115
119
|
let untrusted = false;
|
|
120
|
+
let backing = false; // a Back this router started is traversing: the URL is still the one left
|
|
116
121
|
let inflight: AbortController | undefined;
|
|
117
122
|
const live = announcer(doc);
|
|
118
123
|
const owned = documentHead(doc);
|
|
@@ -120,6 +125,7 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
120
125
|
const tabs = tabSync(win, () => cache.clear());
|
|
121
126
|
const forget = tabs.forget;
|
|
122
127
|
const scroll = scrollKeeper(win, () => rendered);
|
|
128
|
+
const modal = modalController(win, () => rendered, ran, owned);
|
|
123
129
|
|
|
124
130
|
const request = (url: string, purpose: 'soft' | 'prefetch', init?: RequestInit) =>
|
|
125
131
|
fetchDocument(win, doc, url, purpose, init);
|
|
@@ -138,7 +144,11 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
138
144
|
const failed = (url: string, method: string, reason: string): void => {
|
|
139
145
|
const detail = { url, method, reason };
|
|
140
146
|
const event = new CustomEvent(NAVIGATION_ERROR_EVENT, { detail, cancelable: true });
|
|
141
|
-
if (doc.dispatchEvent(event))
|
|
147
|
+
if (!doc.dispatchEvent(event)) return;
|
|
148
|
+
// Under a modal the URL differs only by its hash, which `assign` merely scrolls to: reloaded,
|
|
149
|
+
// the page and its modal are both asked for again (the entry is the router's own GET).
|
|
150
|
+
if (modal.address === undefined) win.location.assign(win.location.href);
|
|
151
|
+
else win.location.reload();
|
|
142
152
|
};
|
|
143
153
|
|
|
144
154
|
const answerFor = (
|
|
@@ -154,22 +164,7 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
154
164
|
const body = options.body === undefined ? {} : { body: options.body };
|
|
155
165
|
return request(url, 'soft', { method, ...body, signal }).finally(forget);
|
|
156
166
|
}
|
|
157
|
-
|
|
158
|
-
cache.delete(url);
|
|
159
|
-
if (held === undefined) return request(url, 'soft', { signal });
|
|
160
|
-
return held.value.then(
|
|
161
|
-
(answer) =>
|
|
162
|
-
reusable({
|
|
163
|
-
status: answer.status,
|
|
164
|
-
html: answer.html !== null,
|
|
165
|
-
location: answer.location,
|
|
166
|
-
noStore: answer.noStore,
|
|
167
|
-
ageMs: held.ageMs,
|
|
168
|
-
})
|
|
169
|
-
? answer
|
|
170
|
-
: request(url, 'soft', { signal }),
|
|
171
|
-
() => request(url, 'soft', { signal }),
|
|
172
|
-
);
|
|
167
|
+
return heldOrFetched(cache, url, () => request(url, 'soft', { signal }));
|
|
173
168
|
};
|
|
174
169
|
|
|
175
170
|
const navigate = async (url: string, options: NavigateOptions = {}): Promise<void> => {
|
|
@@ -183,7 +178,8 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
183
178
|
// After an answer for another principal or build was shown in place, this tab is no longer
|
|
184
179
|
// trusted to swap: every navigation is a real load (`answerMovesTab`).
|
|
185
180
|
if (untrusted && method === 'GET') {
|
|
186
|
-
|
|
181
|
+
if (options.fromHash === true) modal.clear();
|
|
182
|
+
else win.location.assign(url);
|
|
187
183
|
return;
|
|
188
184
|
}
|
|
189
185
|
const detail = { url, method };
|
|
@@ -212,22 +208,20 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
212
208
|
if (mine.signal.aborted) return;
|
|
213
209
|
const next =
|
|
214
210
|
answer.html === null ? null : new DOMParser().parseFromString(answer.html, 'text/html');
|
|
215
|
-
const facts:
|
|
211
|
+
const facts = responseFactsOf(doc, next, answer, { method, requested: url, hops });
|
|
212
|
+
const verdict = responseVerdict(facts);
|
|
213
|
+
const landed = `${withoutFragment(url)}${new URL(url).hash}`;
|
|
214
|
+
const shown = modal.presentationOf(next, landed, {
|
|
215
|
+
fromHash: options.fromHash === true,
|
|
216
216
|
method,
|
|
217
|
-
|
|
217
|
+
verdict: verdict.kind,
|
|
218
218
|
status: answer.status,
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
build: metaOf(doc, CLIENT_BUILD_META),
|
|
226
|
-
nextBuild: answer.build ?? (next === null ? null : metaOf(next, CLIENT_BUILD_META)),
|
|
227
|
-
scope: metaOf(doc, CLIENT_SCOPE_META),
|
|
228
|
-
nextScope: next === null ? null : metaOf(next, CLIENT_SCOPE_META),
|
|
229
|
-
};
|
|
230
|
-
const verdict = responseVerdict(facts);
|
|
219
|
+
movesTab: answerMovesTab(facts),
|
|
220
|
+
});
|
|
221
|
+
if (shown === 'degrade') {
|
|
222
|
+
modal.clear();
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
231
225
|
switch (verdict.kind) {
|
|
232
226
|
case 'stay':
|
|
233
227
|
return;
|
|
@@ -242,7 +236,8 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
242
236
|
return;
|
|
243
237
|
case 'follow':
|
|
244
238
|
await navigate(verdict.url, {
|
|
245
|
-
|
|
239
|
+
// Out of a modal, a redirect's target takes the modal's entry: Back never reopens it.
|
|
240
|
+
history: options.history === 'none' || modal.address !== undefined ? 'replace' : 'push',
|
|
246
241
|
hops: hops + 1,
|
|
247
242
|
});
|
|
248
243
|
return;
|
|
@@ -253,18 +248,26 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
253
248
|
break;
|
|
254
249
|
}
|
|
255
250
|
if (next === null) return;
|
|
256
|
-
|
|
251
|
+
// A page answered while a modal is open: the modal goes, and so does its history entry.
|
|
252
|
+
const leave = shown === 'page' ? modal.leaveFor(options.history, landed) : undefined;
|
|
253
|
+
const history = leave === undefined ? options.history : leave === 'back' ? 'none' : leave;
|
|
257
254
|
try {
|
|
258
255
|
// Owned from the moment they are appended: a navigation aborted after this point left its
|
|
259
256
|
// sheets in the head for good, styling every page after it — the next swap retires an
|
|
260
257
|
// owned sheet its page does not link.
|
|
261
258
|
await loadStylesheets(doc, missingStylesheets(doc, next), owned);
|
|
262
259
|
if (mine.signal.aborted) return;
|
|
260
|
+
if (shown === 'modal') {
|
|
261
|
+
await modal.present(next, addressOf(landed), landed, modalHistory(method, history));
|
|
262
|
+
doc.dispatchEvent(new CustomEvent(NAVIGATED_EVENT, { detail: { url: landed } }));
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (leave === 'back') {
|
|
266
|
+
backing = true;
|
|
267
|
+
win.history.back();
|
|
268
|
+
}
|
|
269
|
+
modal.dismiss();
|
|
263
270
|
let swapped: ReturnType<typeof swapDocument> | undefined;
|
|
264
|
-
const track = (running: RunningTransition, finished: boolean): void => {
|
|
265
|
-
if (!finished) animating = running;
|
|
266
|
-
else if (animating === running) animating = undefined;
|
|
267
|
-
};
|
|
268
271
|
await transition(
|
|
269
272
|
win,
|
|
270
273
|
() => {
|
|
@@ -275,16 +278,16 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
275
278
|
live.textContent = '';
|
|
276
279
|
swapped.body.append(live);
|
|
277
280
|
rendered = withoutFragment(landed);
|
|
278
|
-
if (
|
|
279
|
-
const same = landed === win.location.href ||
|
|
281
|
+
if (history !== 'none') {
|
|
282
|
+
const same = landed === win.location.href || history === 'replace';
|
|
280
283
|
const state = { [STATE_KEY]: { scroll: [0, 0], doc: rendered } satisfies EntryState };
|
|
281
284
|
if (same) win.history.replaceState(state, '', landed);
|
|
282
285
|
else win.history.pushState(state, '', landed);
|
|
283
286
|
}
|
|
284
287
|
// Inside the swap, so a view transition's "after" frame is already where the page lands.
|
|
285
|
-
scrollAfter(win, doc, landed,
|
|
288
|
+
scrollAfter(win, doc, landed, history);
|
|
286
289
|
},
|
|
287
|
-
track,
|
|
290
|
+
press.track,
|
|
288
291
|
);
|
|
289
292
|
const done = swapped;
|
|
290
293
|
if (done === undefined) return;
|
|
@@ -302,6 +305,7 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
302
305
|
focusMain(doc);
|
|
303
306
|
live.textContent = doc.title;
|
|
304
307
|
doc.dispatchEvent(new CustomEvent(NAVIGATED_EVENT, { detail: { url: landed } }));
|
|
308
|
+
reconcile();
|
|
305
309
|
} catch {
|
|
306
310
|
// A swap that failed part-way leaves a page nobody rendered: a GET is loaded for real; a
|
|
307
311
|
// POST's answer cannot be asked for again, so the visitor is told and shown this page.
|
|
@@ -319,64 +323,30 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
319
323
|
};
|
|
320
324
|
|
|
321
325
|
const onPress = (): void => {
|
|
322
|
-
|
|
323
|
-
pressedOverTransition = animating !== undefined;
|
|
326
|
+
press.onPress();
|
|
324
327
|
// A press is not a hover: the click it becomes navigates, and a guess on its heels is a
|
|
325
|
-
// second request for the same page.
|
|
326
|
-
// snapped every named element to its end mid-glide (#621); only the click below may.
|
|
328
|
+
// second request for the same page.
|
|
327
329
|
win.clearTimeout(intent);
|
|
328
330
|
};
|
|
329
|
-
const onRelease = (): void => {
|
|
330
|
-
pressed = false;
|
|
331
|
-
};
|
|
332
331
|
|
|
333
332
|
const onClick = (event: MouseEvent): void => {
|
|
334
333
|
win.clearTimeout(intent);
|
|
335
|
-
|
|
336
|
-
// so whatever `pointer-events` the `::view-transition` overlay has — so the click reached
|
|
337
|
-
// nothing: the visitor's first click after a swap was lost. It is given to the element under
|
|
338
|
-
// the pointer, whatever it is: a link, a submit button, an island's own control. Only a
|
|
339
|
-
// skipped transition hit-tests the page, so the skip happens HERE, at the click, never at
|
|
340
|
-
// the press; when the animation already ended between the two, there is nothing to skip.
|
|
341
|
-
if (pressedOverTransition && event.target === doc.documentElement) {
|
|
342
|
-
pressedOverTransition = false;
|
|
343
|
-
animating?.skipTransition?.();
|
|
344
|
-
const hit = doc.elementFromPoint(event.clientX, event.clientY);
|
|
345
|
-
if (hit !== null && hit !== doc.documentElement && hit instanceof HTMLElement) hit.click();
|
|
346
|
-
return;
|
|
347
|
-
}
|
|
334
|
+
if (press.overlayClick(event)) return;
|
|
348
335
|
const anchor = anchorOf(event.target);
|
|
349
336
|
if (anchor === null) return;
|
|
350
337
|
const verdict = linkVerdict(linkFacts(win, anchor, event));
|
|
351
338
|
if (verdict.kind !== 'soft') return;
|
|
352
339
|
event.preventDefault();
|
|
353
|
-
|
|
340
|
+
// A link to the page beneath an open modal (its Cancel) closes the modal: nothing is fetched.
|
|
341
|
+
if (modal.address !== undefined && withoutFragment(verdict.url) === rendered) modal.close();
|
|
342
|
+
else void navigate(verdict.url);
|
|
354
343
|
};
|
|
355
344
|
|
|
356
345
|
const onSubmit = (event: SubmitEvent): void => {
|
|
357
346
|
const form = event.target;
|
|
358
347
|
// An untrusted tab (`answerMovesTab`) leaves every form to the browser.
|
|
359
348
|
if (!(form instanceof HTMLFormElement) || untrusted) return;
|
|
360
|
-
const
|
|
361
|
-
const fields = formFields(form, submitter);
|
|
362
|
-
const say = (attr: string, fallback: string): string =>
|
|
363
|
-
submitter?.getAttribute(`form${attr}`) ?? form.getAttribute(attr) ?? fallback;
|
|
364
|
-
const facts: FormFacts = {
|
|
365
|
-
action: formAction(say('action', ''), win.location.href, doc.baseURI),
|
|
366
|
-
current: win.location.href,
|
|
367
|
-
method: say('method', 'get').toLowerCase(),
|
|
368
|
-
enctype: say('enctype', 'application/x-www-form-urlencoded').toLowerCase(),
|
|
369
|
-
target: say('target', ''),
|
|
370
|
-
defaultPrevented: event.defaultPrevented,
|
|
371
|
-
reload:
|
|
372
|
-
form.hasAttribute(NAVIGATION_RELOAD_ATTRIBUTE) ||
|
|
373
|
-
submitter?.hasAttribute(NAVIGATION_RELOAD_ATTRIBUTE) === true,
|
|
374
|
-
// `unknown`: a browser yields a `File` for a file input, whatever a server-side type says.
|
|
375
|
-
hasFile: [...fields.values()].some(
|
|
376
|
-
(value: unknown) => value instanceof File && value.name !== '',
|
|
377
|
-
),
|
|
378
|
-
fields: formPairs(fields),
|
|
379
|
-
};
|
|
349
|
+
const { facts, fields } = submitFacts(win, form, event.submitter, event.defaultPrevented);
|
|
380
350
|
const verdict = formVerdict(facts);
|
|
381
351
|
if (verdict.kind === 'native') return;
|
|
382
352
|
event.preventDefault();
|
|
@@ -393,27 +363,12 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
393
363
|
|
|
394
364
|
const onIntent = (event: Event): void => {
|
|
395
365
|
// The focus a press gives a link: its click is a moment away, and it navigates.
|
|
396
|
-
if (event.type === 'focusin' && pressed) return;
|
|
397
|
-
const
|
|
398
|
-
if (
|
|
399
|
-
const verdict = linkVerdict(linkFacts(win, anchor));
|
|
400
|
-
const connection = (
|
|
401
|
-
win.navigator as { connection?: { saveData?: boolean; effectiveType?: string } }
|
|
402
|
-
).connection;
|
|
403
|
-
if (
|
|
404
|
-
verdict.kind !== 'soft' ||
|
|
405
|
-
!mayPrefetch({
|
|
406
|
-
url: verdict.url,
|
|
407
|
-
noPrefetch: anchor.hasAttribute(NAVIGATION_NO_PREFETCH_ATTRIBUTE),
|
|
408
|
-
saveData: connection?.saveData,
|
|
409
|
-
effectiveType: connection?.effectiveType,
|
|
410
|
-
})
|
|
411
|
-
) {
|
|
412
|
-
return;
|
|
413
|
-
}
|
|
366
|
+
if (event.type === 'focusin' && press.pressed()) return;
|
|
367
|
+
const url = intentUrl(win, event.target);
|
|
368
|
+
if (url === undefined) return;
|
|
414
369
|
win.clearTimeout(intent);
|
|
415
370
|
const delay = event.type === 'pointerover' ? NAVIGATION_PREFETCH_DELAY_MS : 0;
|
|
416
|
-
intent = win.setTimeout(() => prefetch(
|
|
371
|
+
intent = win.setTimeout(() => prefetch(url), delay);
|
|
417
372
|
};
|
|
418
373
|
const onLeave = (): void => win.clearTimeout(intent);
|
|
419
374
|
|
|
@@ -424,7 +379,10 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
424
379
|
*/
|
|
425
380
|
const onPop = (event: PopStateEvent): void => {
|
|
426
381
|
scroll.cancel();
|
|
427
|
-
|
|
382
|
+
backing = false;
|
|
383
|
+
// The page is the PATH: a modal's hash is the modal's (`reconcile`), never the page's fragment.
|
|
384
|
+
const at = win.location.href;
|
|
385
|
+
const url = modalAddress(new URL(at).hash, at) === null ? at : withoutFragment(at);
|
|
428
386
|
const entry = entryOf(event.state);
|
|
429
387
|
const wanted = entry?.doc ?? withoutFragment(url);
|
|
430
388
|
const same =
|
|
@@ -442,40 +400,76 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
442
400
|
behavior: 'instant' as ScrollBehavior,
|
|
443
401
|
});
|
|
444
402
|
}
|
|
403
|
+
reconcile();
|
|
445
404
|
};
|
|
446
405
|
|
|
406
|
+
/**
|
|
407
|
+
* The modal follows the URL's hash, once the page on screen is the URL's: opened (fetched, as a
|
|
408
|
+
* hash address, so a stale one degrades), kept, or dismissed. On start, on Back/Forward, on a
|
|
409
|
+
* typed hash, and after every page swap — a Back onto a modal's entry swaps its page first.
|
|
410
|
+
*/
|
|
411
|
+
function reconcile(): void {
|
|
412
|
+
// Until that Back lands, the hash still names the modal just left: it would be opened again.
|
|
413
|
+
if (backing) return;
|
|
414
|
+
const at = win.location.href;
|
|
415
|
+
if (withoutFragment(at) !== rendered) return;
|
|
416
|
+
const wanted = modalAddress(new URL(at).hash, at);
|
|
417
|
+
if (wanted === null) modal.dismiss();
|
|
418
|
+
else if (wanted !== modal.address) {
|
|
419
|
+
void navigate(new URL(wanted, at).href, { history: 'none', fromHash: true });
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
447
423
|
// `window`, bubble phase: after every handler on the page — Solid delegates to `document` — has
|
|
448
424
|
// had its chance to `preventDefault`, which the rules then honour.
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
425
|
+
const passive = { passive: true };
|
|
426
|
+
const pressing = { capture: true, passive: true };
|
|
427
|
+
const listeners: readonly (readonly [EventTarget, string, EventListener, object?])[] = [
|
|
428
|
+
[win, 'click', onClick as EventListener],
|
|
429
|
+
[win, 'submit', onSubmit as EventListener],
|
|
430
|
+
[doc, 'pointerover', onIntent, passive],
|
|
431
|
+
[doc, 'pointerout', onLeave, passive],
|
|
432
|
+
[doc, 'focusin', onIntent],
|
|
433
|
+
[doc, 'touchstart', onIntent, passive],
|
|
434
|
+
[win, 'popstate', onPop as EventListener],
|
|
435
|
+
[win, 'hashchange', reconcile],
|
|
436
|
+
[doc, 'pointerdown', onPress, pressing],
|
|
437
|
+
[doc, 'pointerup', press.onRelease, pressing],
|
|
438
|
+
[doc, 'pointercancel', press.onRelease, pressing],
|
|
439
|
+
];
|
|
440
|
+
for (const [target, type, listener, options] of listeners) {
|
|
441
|
+
target.addEventListener(type, listener, options);
|
|
442
|
+
}
|
|
459
443
|
|
|
460
444
|
const router: NavigationRouter = {
|
|
461
445
|
navigate,
|
|
462
446
|
prefetch,
|
|
447
|
+
refresh() {
|
|
448
|
+
// A fresh answer for the page on screen, landing where the visitor is scrolled.
|
|
449
|
+
cache.delete(rendered);
|
|
450
|
+
scroll.save();
|
|
451
|
+
return navigate(rendered, { history: 'none' });
|
|
452
|
+
},
|
|
453
|
+
openModal(path) {
|
|
454
|
+
const address = modalAddress(`#${path}`, win.location.href);
|
|
455
|
+
if (address === null) return Promise.resolve();
|
|
456
|
+
return navigate(new URL(address, win.location.href).href, {
|
|
457
|
+
fromHash: true,
|
|
458
|
+
history: 'push',
|
|
459
|
+
});
|
|
460
|
+
},
|
|
461
|
+
closeModal: () => modal.close(),
|
|
463
462
|
stop() {
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
doc.removeEventListener('focusin', onIntent);
|
|
469
|
-
doc.removeEventListener('touchstart', onIntent);
|
|
470
|
-
win.removeEventListener('popstate', onPop);
|
|
471
|
-
doc.removeEventListener('pointerdown', onPress, { capture: true });
|
|
472
|
-
doc.removeEventListener('pointerup', onRelease, { capture: true });
|
|
473
|
-
doc.removeEventListener('pointercancel', onRelease, { capture: true });
|
|
463
|
+
for (const [target, type, listener, options] of listeners) {
|
|
464
|
+
target.removeEventListener(type, listener, options);
|
|
465
|
+
}
|
|
466
|
+
modal.dismiss();
|
|
474
467
|
scroll.stop();
|
|
475
468
|
tabs.stop();
|
|
476
469
|
delete win.__xNavigation;
|
|
477
470
|
},
|
|
478
471
|
};
|
|
479
472
|
win.__xNavigation = router;
|
|
473
|
+
reconcile();
|
|
480
474
|
return router;
|
|
481
475
|
}
|
package/src/registry.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The route table — the single source of route truth. File path → URL conventions for
|
|
3
|
-
* `site
|
|
3
|
+
* `site/` and `app/`, plus `describePages()`, the serializable projection that
|
|
4
4
|
* `x.manifest.json`, the `/_x` routes panel, the sitemap and `sw.js` are all generated
|
|
5
5
|
* from. Nothing downstream may keep its own list of routes.
|
|
6
6
|
*/
|
|
@@ -31,40 +31,31 @@ import { isRouteConfig, routeTagKeys } from './route';
|
|
|
31
31
|
import type { RouteComponent } from './route-component';
|
|
32
32
|
import type { CompiledPattern } from './route-pattern';
|
|
33
33
|
import { compilePattern } from './route-pattern';
|
|
34
|
-
import type { Surface } from './surfaces';
|
|
34
|
+
import type { RouteSurface, Surface } from './surfaces';
|
|
35
35
|
import { locateSurface } from './surfaces';
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
|
-
* The one filename a route may carry, per surface.
|
|
39
|
-
*
|
|
40
|
-
* and stating that in the key type makes the other three MANDATORY. `Partial<Record<Surface, …>>`
|
|
41
|
-
* said the same thing about `shared` and let any of the three go missing, which only three
|
|
42
|
-
* registration tests would have caught. A dropped `api` row is not a crash: `assertRouteFilename`
|
|
43
|
-
* reads `undefined` as "this file is under shared/", so every `api/` route author would have been
|
|
44
|
-
* told their file is a leaf of helpers.
|
|
38
|
+
* The one filename a route may carry, per route surface. Keyed by `RouteSurface`, so both rows are
|
|
39
|
+
* MANDATORY: a dropped row would read as `undefined` in `assertRouteFilename`.
|
|
45
40
|
*/
|
|
46
|
-
export const ROUTE_FILENAME = Object.freeze<Record<
|
|
41
|
+
export const ROUTE_FILENAME = Object.freeze<Record<RouteSurface, string>>({
|
|
47
42
|
site: 'page.tsx',
|
|
48
43
|
app: 'page.tsx',
|
|
49
|
-
api: 'route.ts',
|
|
50
44
|
});
|
|
51
45
|
|
|
52
46
|
/** Stems that already meant "this directory", so the repair is a rename in place, not a new folder. */
|
|
53
47
|
const DIRECTORY_STEMS = new Set(['index', 'page', 'route']);
|
|
54
48
|
|
|
55
|
-
const API_PREFIX = '/api';
|
|
56
|
-
|
|
57
49
|
export interface RouteEntry<TData = RouteData> {
|
|
58
50
|
readonly file: string;
|
|
59
51
|
readonly path: string;
|
|
60
|
-
readonly surface:
|
|
52
|
+
readonly surface: RouteSurface;
|
|
61
53
|
readonly config: RouteConfig<TData>;
|
|
62
54
|
readonly suspenseBoundaries: number;
|
|
63
55
|
readonly islands: readonly string[];
|
|
64
56
|
readonly pattern: CompiledPattern;
|
|
65
57
|
/**
|
|
66
|
-
* The module's page component. Absent for
|
|
67
|
-
* a `spa` shell is the mode that legitimately has no server-rendered body.
|
|
58
|
+
* The module's page component. Absent for a module that exports none.
|
|
68
59
|
*/
|
|
69
60
|
readonly component?: RouteComponent;
|
|
70
61
|
}
|
|
@@ -119,9 +110,8 @@ export type { MountedRouteInput, RouteMount, RouteMountInput } from './mounted-r
|
|
|
119
110
|
* | `site/blog/[slug]/page.tsx` | `/blog/:slug` |
|
|
120
111
|
* | `site/docs/[...path]/page.tsx` | `/docs/*path` |
|
|
121
112
|
* | `app/dashboard/page.tsx` | `/dashboard` |
|
|
122
|
-
* | `api/posts/route.ts` | `/api/posts` |
|
|
123
113
|
*/
|
|
124
|
-
export function routePathFromFile(file: string): { surface:
|
|
114
|
+
export function routePathFromFile(file: string): { surface: RouteSurface; path: string } {
|
|
125
115
|
const normalized = file.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
126
116
|
// One reader of the surface segment, and it answers WHERE as well as WHICH. Slicing at
|
|
127
117
|
// `indexOf('app/')` instead matched inside `myapp/`, so `apps/myapp/app/page.tsx` resolved to
|
|
@@ -130,10 +120,10 @@ export function routePathFromFile(file: string): { surface: Surface; path: strin
|
|
|
130
120
|
if (located === null) {
|
|
131
121
|
throw new SurfaceBoundaryError(
|
|
132
122
|
`${file} is not inside a surface directory, so it has no URL and no bundle graph`,
|
|
133
|
-
`move ${file} under site
|
|
123
|
+
`move ${file} under site/ or app/, named page.tsx`,
|
|
134
124
|
);
|
|
135
125
|
}
|
|
136
|
-
const surface = located.surface;
|
|
126
|
+
const surface = routeSurfaceOf(normalized, located.surface);
|
|
137
127
|
|
|
138
128
|
const rawSegments = located.rest.split('/').filter((s) => s.length > 0);
|
|
139
129
|
assertRouteFilename(normalized, surface, rawSegments[rawSegments.length - 1]);
|
|
@@ -143,8 +133,7 @@ export function routePathFromFile(file: string): { surface: Surface; path: strin
|
|
|
143
133
|
.filter((s) => !(s.startsWith('(') && s.endsWith(')')))
|
|
144
134
|
.map(toUrlSegment);
|
|
145
135
|
|
|
146
|
-
const
|
|
147
|
-
const path = `${base}/${urlSegments.join('/')}`.replace(/\/+$/, '') || '/';
|
|
136
|
+
const path = `/${urlSegments.join('/')}`.replace(/\/+$/, '') || '/';
|
|
148
137
|
return { surface, path };
|
|
149
138
|
}
|
|
150
139
|
|
|
@@ -156,20 +145,37 @@ export function routePathFromFile(file: string): { surface: Surface; path: strin
|
|
|
156
145
|
const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`;
|
|
157
146
|
|
|
158
147
|
/**
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
148
|
+
* Only `site/` and `app/` hold route files. `shared/` is a leaf of helpers with no URL; `api/` is
|
|
149
|
+
* the action and query projections `defineApi()` collects — a wire format an action cannot speak
|
|
150
|
+
* is a plain HTTP route in the `routes` runtime override, never a file under `api/`.
|
|
162
151
|
*/
|
|
163
|
-
function
|
|
164
|
-
|
|
165
|
-
// this is a comparison rather than an `undefined` check on the lookup.
|
|
166
|
-
const expected = surface === 'shared' ? undefined : ROUTE_FILENAME[surface];
|
|
167
|
-
if (expected === undefined) {
|
|
152
|
+
function routeSurfaceOf(file: string, surface: Surface): RouteSurface {
|
|
153
|
+
if (surface === 'shared') {
|
|
168
154
|
throw new RouteFileInvalidError(
|
|
169
155
|
`${file} is under shared/, which is a leaf of helpers with no URL — a route cannot live there`,
|
|
170
|
-
`move ${file} under site
|
|
156
|
+
`move ${file} under site/ or app/, named page.tsx`,
|
|
171
157
|
);
|
|
172
158
|
}
|
|
159
|
+
if (surface === 'api') {
|
|
160
|
+
throw new RouteFileInvalidError(
|
|
161
|
+
`${file} is under api/, which holds no route file: api/ is the actions and queries defineApi() collects`,
|
|
162
|
+
`JSON in and out: replace ${file} with an action (x g action <name>) collected by defineApi() in api/index.ts. Another wire format (a webhook, an OAuth endpoint): delete ${file} and add a plain HTTP route to the routes array exported as runtime from apps/<app>/runtime.ts`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
return surface;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Enforced rather than documented (axiom 3): a convention that is not a build error is not a
|
|
170
|
+
* convention. The fix is the move that makes the file a route, spelled out — the directory the
|
|
171
|
+
* author already meant, plus the one filename that surface accepts.
|
|
172
|
+
*/
|
|
173
|
+
function assertRouteFilename(
|
|
174
|
+
file: string,
|
|
175
|
+
surface: RouteSurface,
|
|
176
|
+
basename: string | undefined,
|
|
177
|
+
): void {
|
|
178
|
+
const expected = ROUTE_FILENAME[surface];
|
|
173
179
|
if (basename === expected) return;
|
|
174
180
|
|
|
175
181
|
// The directory the author meant is the file's own path minus its extension: `site/pricing.tsx`
|
package/src/route.ts
CHANGED
|
@@ -191,11 +191,19 @@ export type RouteCache = 'no-store' | Omit<CacheHint, 'tags'>;
|
|
|
191
191
|
* in on a click, never fetched before one. `'prefetch'`: may also be fetched on intent — only for a
|
|
192
192
|
* page whose GET does nothing but render. `'document'`: always a real document load, the route run
|
|
193
193
|
* once by the browser — for a GET that records something (an open, a download, a token consumed).
|
|
194
|
-
*
|
|
194
|
+
* `'modal'`: presented OVER the page a visitor came from, addressed by the hash
|
|
195
|
+
* (`/runs#/runs/new`) — a create form, a confirmation; a full load of its own URL is still the
|
|
196
|
+
* whole page. Never fetched before a click. The server enforces all three before `load`
|
|
197
|
+
* (`@ultimat3/http`'s navigation gate) and names a modal in its document
|
|
198
|
+
* (`NAVIGATION_PRESENTATION_META`).
|
|
195
199
|
*/
|
|
196
|
-
export type RouteNavigationMode = 'prefetch' | 'document';
|
|
200
|
+
export type RouteNavigationMode = 'prefetch' | 'document' | 'modal';
|
|
197
201
|
|
|
198
|
-
export const ROUTE_NAVIGATION_MODES: readonly RouteNavigationMode[] = [
|
|
202
|
+
export const ROUTE_NAVIGATION_MODES: readonly RouteNavigationMode[] = [
|
|
203
|
+
'prefetch',
|
|
204
|
+
'document',
|
|
205
|
+
'modal',
|
|
206
|
+
];
|
|
199
207
|
|
|
200
208
|
/** The input shape of `defineRoute` — exactly the contract's twelve keys, nothing else. */
|
|
201
209
|
export interface RouteDefinition<TData = RouteData> {
|
package/src/surfaces.ts
CHANGED
|
@@ -11,6 +11,12 @@ import { SurfaceBoundaryError } from './errors';
|
|
|
11
11
|
|
|
12
12
|
export type Surface = 'site' | 'app' | 'api' | 'shared';
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* The two surfaces a route file lives on. `api/` is a surface for the import boundary only: it holds
|
|
16
|
+
* the action and query projections `defineApi()` collects, and no route file.
|
|
17
|
+
*/
|
|
18
|
+
export type RouteSurface = Exclude<Surface, 'api' | 'shared'>;
|
|
19
|
+
|
|
14
20
|
export const SURFACES = ['site', 'app', 'api', 'shared'] as const;
|
|
15
21
|
|
|
16
22
|
export interface SurfaceSpec {
|