@tinvar/react 0.0.0-stage → 0.4.1
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/LICENSE +21 -0
- package/package.json +168 -4
- package/src/action-server.js +41 -0
- package/src/action.d.ts +68 -0
- package/src/action.js +167 -0
- package/src/bootstrap.js +153 -0
- package/src/channel.d.ts +46 -0
- package/src/channel.js +132 -0
- package/src/client-modules.d.ts +8 -0
- package/src/client-modules.js +88 -0
- package/src/client.d.ts +36 -0
- package/src/client.js +42 -0
- package/src/compose-route.d.ts +33 -0
- package/src/compose-route.js +107 -0
- package/src/csrf.d.ts +11 -0
- package/src/csrf.js +49 -0
- package/src/deferred-boundary.d.ts +61 -0
- package/src/deferred-boundary.js +646 -0
- package/src/deferred-groups.d.ts +24 -0
- package/src/deferred-groups.js +198 -0
- package/src/deferred-revalidation.d.ts +37 -0
- package/src/deferred-revalidation.js +108 -0
- package/src/deferred-triggers.d.ts +26 -0
- package/src/deferred-triggers.js +172 -0
- package/src/deferred.d.ts +24 -0
- package/src/deferred.jsx +100 -0
- package/src/document-load.js +95 -0
- package/src/endpoint.d.ts +87 -0
- package/src/endpoint.js +304 -0
- package/src/form-data.js +219 -0
- package/src/form.d.ts +177 -0
- package/src/form.js +615 -0
- package/src/forms.d.ts +4 -0
- package/src/forms.js +10 -0
- package/src/fragment-client.d.ts +70 -0
- package/src/fragment-client.js +510 -0
- package/src/head.d.ts +85 -0
- package/src/head.js +360 -0
- package/src/inbox-view.d.ts +17 -0
- package/src/inbox-view.js +105 -0
- package/src/inbox.d.ts +108 -0
- package/src/inbox.js +293 -0
- package/src/index.combined.d.ts +31 -0
- package/src/index.d.ts +5 -0
- package/src/index.js +29 -0
- package/src/index.server.d.ts +4 -0
- package/src/index.server.js +8 -0
- package/src/infinite-scroll.d.ts +108 -0
- package/src/infinite-scroll.js +715 -0
- package/src/layout-props.d.ts +30 -0
- package/src/layout-props.js +143 -0
- package/src/link-click.d.ts +3 -0
- package/src/link-click.js +68 -0
- package/src/link.d.ts +63 -0
- package/src/link.js +274 -0
- package/src/live.d.ts +65 -0
- package/src/live.js +977 -0
- package/src/merge-slot.d.ts +41 -0
- package/src/merge-slot.js +253 -0
- package/src/merge.d.ts +111 -0
- package/src/merge.js +149 -0
- package/src/once-slot.d.ts +14 -0
- package/src/once-slot.js +75 -0
- package/src/once-store.d.ts +26 -0
- package/src/once-store.js +211 -0
- package/src/once.d.ts +44 -0
- package/src/once.js +65 -0
- package/src/optimistic.d.ts +14 -0
- package/src/optimistic.js +159 -0
- package/src/optional.d.ts +10 -0
- package/src/optional.js +31 -0
- package/src/page-root.d.ts +56 -0
- package/src/page-root.js +455 -0
- package/src/payload.d.ts +15 -0
- package/src/payload.js +101 -0
- package/src/poll.d.ts +55 -0
- package/src/poll.js +224 -0
- package/src/precognition.js +190 -0
- package/src/prefetch-cache.d.ts +46 -0
- package/src/prefetch-cache.js +256 -0
- package/src/progress.d.ts +38 -0
- package/src/progress.js +243 -0
- package/src/remember-store.d.ts +50 -0
- package/src/remember-store.js +486 -0
- package/src/rescue.d.ts +10 -0
- package/src/rescue.js +30 -0
- package/src/resumable.js +822 -0
- package/src/resume-store.js +364 -0
- package/src/route.d.ts +50 -0
- package/src/route.js +227 -0
- package/src/router-hooks.js +234 -0
- package/src/router.d.ts +586 -0
- package/src/router.js +3555 -0
- package/src/segment.d.ts +36 -0
- package/src/segment.js +116 -0
- package/src/server.d.ts +71 -0
- package/src/server.js +242 -0
- package/src/submit.js +333 -0
- package/src/telemetry.d.ts +71 -0
- package/src/telemetry.js +851 -0
- package/src/testing.d.ts +89 -0
- package/src/testing.js +634 -0
- package/src/transfer.d.ts +48 -0
- package/src/transfer.js +135 -0
- package/src/transport.js +390 -0
- package/src/upload.d.ts +342 -0
- package/src/upload.js +1240 -0
- package/src/use-channel.d.ts +21 -0
- package/src/use-channel.js +51 -0
- package/src/use-form.d.ts +86 -0
- package/src/use-form.js +432 -0
- package/src/use-http.d.ts +56 -0
- package/src/use-http.js +248 -0
- package/src/use-inbox.d.ts +41 -0
- package/src/use-inbox.js +201 -0
- package/src/use-remember.d.ts +10 -0
- package/src/use-remember.js +132 -0
- package/src/when-mounted.d.ts +15 -0
- package/src/when-mounted.js +27 -0
- package/README.md +0 -3
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** What a page root keeps of the values pages hand their layouts. */
|
|
2
|
+
export interface LayoutPropsStore {
|
|
3
|
+
read(layout: string | undefined): Record<string, unknown>;
|
|
4
|
+
set(layout: string | undefined, props: Record<string, unknown>): void;
|
|
5
|
+
mark(): number;
|
|
6
|
+
dropBefore(mark: number): void;
|
|
7
|
+
subscribe(listener: () => void): () => void;
|
|
8
|
+
version(): number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** @internal The page root's store; createPageRoot() makes one for its life. */
|
|
12
|
+
export declare function createLayoutPropsStore(): LayoutPropsStore;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* In a layout's client component: what the page on screen set for this
|
|
16
|
+
* layout -- by its key -- or for every layout, over `defaults`. The defaults
|
|
17
|
+
* alone outside a page root, and on the server.
|
|
18
|
+
*/
|
|
19
|
+
export declare function useLayoutProps<P extends Record<string, unknown>>(defaults: P, layout?: string): P;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The setter a page's client component hands its layouts values with,
|
|
23
|
+
* kept until a whole page is shown (ADR 0092). Merged with what was set
|
|
24
|
+
* before; undefined takes a value back. Call it from an effect or an event
|
|
25
|
+
* handler, not while rendering. Does nothing outside a page root.
|
|
26
|
+
*/
|
|
27
|
+
export declare function useSetLayoutProps(): <P extends Record<string, unknown>>(
|
|
28
|
+
props: Partial<P>,
|
|
29
|
+
layout?: string,
|
|
30
|
+
) => void;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// setLayoutProps: client-side state a page hands the layouts around it.
|
|
4
|
+
//
|
|
5
|
+
// tinvar-spec.md, "What not to port": "Keep typed layout props and
|
|
6
|
+
// `setLayoutProps` for client-side layout state." Inertia's persistent
|
|
7
|
+
// layouts are client components a page passes props to. Here a layout is a
|
|
8
|
+
// server component, drawn from its own loader's data, and nothing on the
|
|
9
|
+
// client can give it props (ADR 0052: the client holds no props to patch).
|
|
10
|
+
// What a page can reach is the layout's client components -- a header's
|
|
11
|
+
// title slot, a sidebar's open section -- so that is what this carries: a
|
|
12
|
+
// page's client component sets values with the setter useSetLayoutProps()
|
|
13
|
+
// hands it, and a layout's client component reads them with
|
|
14
|
+
// useLayoutProps(), over defaults of its own (ADR 0092).
|
|
15
|
+
//
|
|
16
|
+
// The values belong to the page that set them. The page root keeps them,
|
|
17
|
+
// one store for its life, and drops what was set before a whole page is
|
|
18
|
+
// shown -- a visit, back or forward -- as Inertia resets layout props on
|
|
19
|
+
// navigation; a partial reload keeps them. Never a module-level store: in
|
|
20
|
+
// the server's render of a document, one store would be every request's,
|
|
21
|
+
// and a value one visitor's page set would be read by another's layout.
|
|
22
|
+
// Outside a page root -- that server render -- a setter does nothing and a
|
|
23
|
+
// layout reads its defaults, which is also what the browser hydrates with.
|
|
24
|
+
|
|
25
|
+
import { useCallback, useContext, useSyncExternalStore } from 'react';
|
|
26
|
+
import { SegmentContext } from './segment.js';
|
|
27
|
+
|
|
28
|
+
/** Every layout's values, as opposed to one layout's by its key. */
|
|
29
|
+
const EVERY_LAYOUT = '';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* What the page root keeps: values by layout key, each value with the
|
|
33
|
+
* number of the change that set it.
|
|
34
|
+
*
|
|
35
|
+
* @typedef {{
|
|
36
|
+
* read: (layout: string | undefined) => Record<string, unknown>,
|
|
37
|
+
* set: (layout: string | undefined, props: Record<string, unknown>) => void,
|
|
38
|
+
* mark: () => number,
|
|
39
|
+
* dropBefore: (mark: number) => void,
|
|
40
|
+
* subscribe: (listener: () => void) => () => void,
|
|
41
|
+
* version: () => number,
|
|
42
|
+
* }} LayoutPropsStore
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/** @returns {LayoutPropsStore} */
|
|
46
|
+
export function createLayoutPropsStore() {
|
|
47
|
+
/** @type {Map<string, Map<string, { value: unknown, at: number }>>} */
|
|
48
|
+
const layouts = new Map();
|
|
49
|
+
const listeners = new Set();
|
|
50
|
+
let changes = 0;
|
|
51
|
+
let version = 0;
|
|
52
|
+
|
|
53
|
+
function changed() {
|
|
54
|
+
version++;
|
|
55
|
+
for (const listener of [...listeners]) listener();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
read(layout) {
|
|
60
|
+
/** @type {Record<string, unknown>} */
|
|
61
|
+
const out = {};
|
|
62
|
+
for (const key of layout === undefined ? [EVERY_LAYOUT] : [EVERY_LAYOUT, layout]) {
|
|
63
|
+
for (const [name, { value }] of layouts.get(key) ?? []) out[name] = value;
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
set(layout, props) {
|
|
69
|
+
if (props === null || typeof props !== 'object' || Array.isArray(props)) {
|
|
70
|
+
throw new TypeError('[tinvar] setLayoutProps() takes an object of props.');
|
|
71
|
+
}
|
|
72
|
+
const key = layout ?? EVERY_LAYOUT;
|
|
73
|
+
const values = layouts.get(key) ?? new Map();
|
|
74
|
+
const at = ++changes;
|
|
75
|
+
for (const [name, value] of Object.entries(props)) {
|
|
76
|
+
// Undefined takes a value back, and the layout's default shows.
|
|
77
|
+
if (value === undefined) values.delete(name);
|
|
78
|
+
else values.set(name, { value, at });
|
|
79
|
+
}
|
|
80
|
+
layouts.set(key, values);
|
|
81
|
+
changed();
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
mark() {
|
|
85
|
+
return changes;
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
dropBefore(mark) {
|
|
89
|
+
let dropped = false;
|
|
90
|
+
for (const values of layouts.values()) {
|
|
91
|
+
for (const [name, { at }] of values) {
|
|
92
|
+
if (at <= mark) {
|
|
93
|
+
values.delete(name);
|
|
94
|
+
dropped = true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (dropped) changed();
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
subscribe(listener) {
|
|
102
|
+
listeners.add(listener);
|
|
103
|
+
return () => listeners.delete(listener);
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
version() {
|
|
107
|
+
return version;
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const NO_STORE = () => () => {};
|
|
113
|
+
const NOTHING_SET = () => 0;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The values the page on screen set for this layout -- or for every
|
|
117
|
+
* layout -- over `defaults`. In a layout's client component.
|
|
118
|
+
*
|
|
119
|
+
* @template {Record<string, unknown>} P
|
|
120
|
+
* @param {P} defaults
|
|
121
|
+
* @param {string} [layout] the layout's key; without one, only what was set for every layout
|
|
122
|
+
* @returns {P}
|
|
123
|
+
*/
|
|
124
|
+
export function useLayoutProps(defaults, layout) {
|
|
125
|
+
const store = useContext(SegmentContext)?.layoutProps ?? null;
|
|
126
|
+
useSyncExternalStore(store?.subscribe ?? NO_STORE, store?.version ?? NOTHING_SET, NOTHING_SET);
|
|
127
|
+
|
|
128
|
+
return store === null ? defaults : /** @type {P} */ ({ ...defaults, ...store.read(layout) });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The setter a page's client component hands its layouts values with:
|
|
133
|
+
* `setLayoutProps({ title })` for every layout, or `setLayoutProps({ open:
|
|
134
|
+
* 'filters' }, 'shop')` for the layout with that key. Merged with what was
|
|
135
|
+
* set before; undefined takes a value back. Kept until a whole page is
|
|
136
|
+
* shown. Call it from an effect or an event handler, not while rendering.
|
|
137
|
+
*
|
|
138
|
+
* @returns {(props: Record<string, unknown>, layout?: string) => void}
|
|
139
|
+
*/
|
|
140
|
+
export function useSetLayoutProps() {
|
|
141
|
+
const store = useContext(SegmentContext)?.layoutProps ?? null;
|
|
142
|
+
return useCallback((props, layout) => store?.set(layout, props), [store]);
|
|
143
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export declare function shouldIntercept(event: MouseEvent, anchor: HTMLAnchorElement): boolean;
|
|
2
|
+
/** The URL a plain click on this link would make a client visit to, or null when the browser would handle it. */
|
|
3
|
+
export declare function visitTarget(anchor: HTMLAnchorElement): URL | null;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Which clicks on a link the router takes over, and which it leaves alone.
|
|
2
|
+
//
|
|
3
|
+
// A link that is handled in place must still behave as a link everywhere the
|
|
4
|
+
// user expects a link: a middle click, a modifier click, a "download" or a
|
|
5
|
+
// target opens what it always opened. The router takes over only the plain
|
|
6
|
+
// primary click that would have replaced this page with another page of
|
|
7
|
+
// this application -- every other case is the browser's.
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {MouseEvent} event
|
|
11
|
+
* @param {HTMLAnchorElement} anchor
|
|
12
|
+
* @returns {boolean} whether the router should make this visit
|
|
13
|
+
*/
|
|
14
|
+
export function shouldIntercept(event, anchor) {
|
|
15
|
+
// Something else -- the app's own onClick -- has already decided.
|
|
16
|
+
if (event.defaultPrevented) return false;
|
|
17
|
+
|
|
18
|
+
// Primary button, no modifiers: anything else opens a tab, a window, a
|
|
19
|
+
// download or a context menu.
|
|
20
|
+
if (event.button !== 0) return false;
|
|
21
|
+
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return false;
|
|
22
|
+
|
|
23
|
+
return visitTarget(anchor) !== null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The URL a plain click on this link would make a client visit to, or null
|
|
28
|
+
* when the click would be the browser's. What a prefetch asks for too: a
|
|
29
|
+
* page fetched for a link the router would never follow is a page nobody
|
|
30
|
+
* can be shown.
|
|
31
|
+
*
|
|
32
|
+
* @param {HTMLAnchorElement} anchor
|
|
33
|
+
* @returns {URL | null}
|
|
34
|
+
*/
|
|
35
|
+
export function visitTarget(anchor) {
|
|
36
|
+
// Keywords match case-insensitively; " _self" with a space is a named
|
|
37
|
+
// window, as browsers treat it.
|
|
38
|
+
const target = anchor.getAttribute('target');
|
|
39
|
+
if (target && target.toLowerCase() !== '_self') return null;
|
|
40
|
+
if (anchor.hasAttribute('download')) return null;
|
|
41
|
+
|
|
42
|
+
const win = anchor.ownerDocument.defaultView;
|
|
43
|
+
if (!win) return null;
|
|
44
|
+
|
|
45
|
+
// From the attribute against the element's base URI, not `.href`: on an
|
|
46
|
+
// SVG <a> that is an SVGAnimatedString, which stringified to a junk
|
|
47
|
+
// same-origin path and passed the origin check whatever it linked to.
|
|
48
|
+
const raw = anchor.getAttribute('href');
|
|
49
|
+
if (raw === null) return null;
|
|
50
|
+
|
|
51
|
+
let url;
|
|
52
|
+
try {
|
|
53
|
+
url = new URL(raw, anchor.baseURI);
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Another origin, or not a place at all (mailto:, tel:): an ordinary link.
|
|
59
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
|
60
|
+
if (url.origin !== win.location.origin) return null;
|
|
61
|
+
|
|
62
|
+
// Only the fragment differs: the browser jumps to it within the page,
|
|
63
|
+
// and the router's popstate handling already treats that as a scroll.
|
|
64
|
+
const here = new URL(win.location.href);
|
|
65
|
+
if (url.hash && url.pathname === here.pathname && url.search === here.search) return null;
|
|
66
|
+
|
|
67
|
+
return url;
|
|
68
|
+
}
|
package/src/link.d.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { AnchorHTMLAttributes, ComponentType, MouseEventHandler, ReactNode } from 'react';
|
|
2
|
+
import type { CacheFor, Router, VisitOptions } from './router';
|
|
3
|
+
|
|
4
|
+
/** When a link prefetches: resting the pointer on it, pressing it, or as soon as it is on screen. */
|
|
5
|
+
export type PrefetchMode = 'hover' | 'click' | 'mount';
|
|
6
|
+
|
|
7
|
+
/** How long the pointer rests on a link before it is prefetched, in ms. */
|
|
8
|
+
export declare const HOVER_DELAY_MS: number;
|
|
9
|
+
|
|
10
|
+
export interface LinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href'> {
|
|
11
|
+
href: string;
|
|
12
|
+
replace?: boolean;
|
|
13
|
+
/** Keep the window and the scroll regions where they are; a function decides once the response is in. */
|
|
14
|
+
preserveScroll?: VisitOptions['preserveScroll'];
|
|
15
|
+
/** A partial reload of the segments these dot paths name; see VisitOptions. */
|
|
16
|
+
only?: readonly string[];
|
|
17
|
+
/** A partial reload of every segment but those named. */
|
|
18
|
+
except?: readonly string[];
|
|
19
|
+
/** Keep the page's client components' state; a visit mounts the next page fresh unless told so. */
|
|
20
|
+
preserveState?: VisitOptions['preserveState'];
|
|
21
|
+
/** Mount the next page inside a view transition, where the browser has them. */
|
|
22
|
+
viewTransition?: VisitOptions['viewTransition'];
|
|
23
|
+
/**
|
|
24
|
+
* Show the layouts on screen and a layout's loading boundary while the
|
|
25
|
+
* page is fetched: the one above the page given true, or the layout with
|
|
26
|
+
* this key's (ADR 0092). See VisitOptions.
|
|
27
|
+
*/
|
|
28
|
+
instant?: VisitOptions['instant'];
|
|
29
|
+
/** `true` is `'hover'`. Only links the router would follow are prefetched. */
|
|
30
|
+
prefetch?: boolean | PrefetchMode | readonly PrefetchMode[];
|
|
31
|
+
/** How long the prefetched page is used for; 30 seconds unless given. */
|
|
32
|
+
cacheFor?: CacheFor;
|
|
33
|
+
/** Tags for the prefetched page: an action invalidating one flushes it. */
|
|
34
|
+
cacheTags?: string | readonly string[];
|
|
35
|
+
/**
|
|
36
|
+
* Rendered in place of the <a>, given the href, the handlers and
|
|
37
|
+
* data-loading -- a design system's styled link. It must render an <a>
|
|
38
|
+
* that takes those props.
|
|
39
|
+
*/
|
|
40
|
+
as?:
|
|
41
|
+
| 'a'
|
|
42
|
+
| ComponentType<{
|
|
43
|
+
href: string;
|
|
44
|
+
onClick?: MouseEventHandler<HTMLAnchorElement>;
|
|
45
|
+
onMouseEnter?: MouseEventHandler<HTMLAnchorElement>;
|
|
46
|
+
onMouseLeave?: MouseEventHandler<HTMLAnchorElement>;
|
|
47
|
+
onMouseDown?: MouseEventHandler<HTMLAnchorElement>;
|
|
48
|
+
'data-loading'?: string;
|
|
49
|
+
children?: ReactNode;
|
|
50
|
+
}>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** An <a href> that makes a client visit on a plain click. */
|
|
54
|
+
export declare function Link(props: LinkProps): ReactNode;
|
|
55
|
+
|
|
56
|
+
/** Makes a router available to every <Link> and useRouter() below it. */
|
|
57
|
+
export declare function RouterProvider(props: { router: Router; children?: ReactNode }): ReactNode;
|
|
58
|
+
|
|
59
|
+
/** The router from the nearest RouterProvider; throws without one. */
|
|
60
|
+
export declare function useRouter(): Router;
|
|
61
|
+
|
|
62
|
+
/** The router from the nearest RouterProvider, or null without one -- a server render. */
|
|
63
|
+
export declare function useOptionalRouter(): Router | null;
|
package/src/link.js
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// <Link> and the context that gives it a router.
|
|
4
|
+
//
|
|
5
|
+
// tinvar-spec.md, "Client router and components": "`<Link>`: `href` takes a
|
|
6
|
+
// string or a generated typed route; ... `data-loading` while in flight;
|
|
7
|
+
// `replace`, `preserveState`, `preserveScroll` ... `only`, `viewTransition`."
|
|
8
|
+
// This is the GET core: href, replace, preserveState, preserveScroll,
|
|
9
|
+
// only and except, data-loading, prefetch and viewTransition. Pages are GET
|
|
10
|
+
// only, so a link is never a <button>; typed routes build on it.
|
|
11
|
+
//
|
|
12
|
+
// It renders a real <a href>. Without JavaScript, or without a router above
|
|
13
|
+
// it, it is an ordinary link and the server renders the next page; with one,
|
|
14
|
+
// a plain click becomes a client visit. Which clicks count is link-click.js.
|
|
15
|
+
//
|
|
16
|
+
// `as` renders another component in place of the <a> -- a design system's
|
|
17
|
+
// styled link -- with the same href, handlers and data-loading. It must
|
|
18
|
+
// render an <a> that takes those props: the click rules read the element the
|
|
19
|
+
// handler is on, and only an anchor has the href they resolve.
|
|
20
|
+
|
|
21
|
+
import { createContext, createElement, useContext, useEffect, useRef, useState } from 'react';
|
|
22
|
+
import { shouldIntercept, visitTarget } from './link-click.js';
|
|
23
|
+
|
|
24
|
+
const RouterContext = createContext(null);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* How long the pointer rests on a link before it is prefetched. "`<Link
|
|
28
|
+
* prefetch>` on hover (75 ms default)": long enough that sweeping the
|
|
29
|
+
* pointer across a page of links fetches none of them.
|
|
30
|
+
*/
|
|
31
|
+
export const HOVER_DELAY_MS = 75;
|
|
32
|
+
|
|
33
|
+
/** @typedef {'hover' | 'click' | 'mount'} PrefetchMode */
|
|
34
|
+
|
|
35
|
+
const MODES = new Set(['hover', 'click', 'mount']);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Makes a router available to every <Link> and useRouter() below it.
|
|
39
|
+
*
|
|
40
|
+
* @param {{ router: import('./router.js').Router | object, children?: unknown }} props
|
|
41
|
+
*/
|
|
42
|
+
export function RouterProvider({ router, children }) {
|
|
43
|
+
return createElement(RouterContext.Provider, { value: router }, children);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The router from the nearest RouterProvider. */
|
|
47
|
+
export function useRouter() {
|
|
48
|
+
const router = useContext(RouterContext);
|
|
49
|
+
if (router === null) {
|
|
50
|
+
throw new Error('[tinvar:client] useRouter() needs a <RouterProvider> above it.');
|
|
51
|
+
}
|
|
52
|
+
return router;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The router from the nearest RouterProvider, or null where there is none
|
|
57
|
+
* -- a server render, or a test.
|
|
58
|
+
*/
|
|
59
|
+
export function useOptionalRouter() {
|
|
60
|
+
return useContext(RouterContext);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* When a link prefetches: `true` is on hover; otherwise one mode or several.
|
|
65
|
+
*
|
|
66
|
+
* @param {boolean | PrefetchMode | readonly PrefetchMode[] | undefined} prefetch
|
|
67
|
+
* @returns {Set<PrefetchMode>}
|
|
68
|
+
*/
|
|
69
|
+
function modesOf(prefetch) {
|
|
70
|
+
if (prefetch === true) return new Set(['hover']);
|
|
71
|
+
const listed = typeof prefetch === 'string' ? [prefetch] : Array.isArray(prefetch) ? prefetch : [];
|
|
72
|
+
return new Set(listed.filter((mode) => MODES.has(mode)));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Whether a link goes to one of the application's endpoints, by the table
|
|
77
|
+
* the router was given (ADR 0074). A router without one -- or a stand-in
|
|
78
|
+
* object without the method -- knows of none.
|
|
79
|
+
*
|
|
80
|
+
* @param {unknown} router
|
|
81
|
+
* @param {HTMLAnchorElement} anchor
|
|
82
|
+
*/
|
|
83
|
+
function isEndpointLink(router, anchor) {
|
|
84
|
+
const check = /** @type {{ isEndpoint?: (url: URL) => boolean } | null} */ (router)?.isEndpoint;
|
|
85
|
+
if (typeof check !== 'function') return false;
|
|
86
|
+
const url = visitTarget(anchor);
|
|
87
|
+
return url !== null && check.call(router, url);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @param {{
|
|
92
|
+
* href: string,
|
|
93
|
+
* replace?: boolean,
|
|
94
|
+
* preserveScroll?: import('./router.js').VisitOptions['preserveScroll'],
|
|
95
|
+
* preserveState?: import('./router.js').VisitOptions['preserveState'],
|
|
96
|
+
* only?: readonly string[],
|
|
97
|
+
* except?: readonly string[],
|
|
98
|
+
* viewTransition?: import('./router.js').VisitOptions['viewTransition'],
|
|
99
|
+
* instant?: boolean | string,
|
|
100
|
+
* prefetch?: boolean | PrefetchMode | readonly PrefetchMode[],
|
|
101
|
+
* cacheFor?: import('./router.js').PrefetchOptions['cacheFor'],
|
|
102
|
+
* cacheTags?: string | readonly string[],
|
|
103
|
+
* as?: string | Function,
|
|
104
|
+
* onClick?: (event: MouseEvent) => void,
|
|
105
|
+
* onMouseEnter?: (event: MouseEvent) => void,
|
|
106
|
+
* onMouseLeave?: (event: MouseEvent) => void,
|
|
107
|
+
* onMouseDown?: (event: MouseEvent) => void,
|
|
108
|
+
* children?: unknown,
|
|
109
|
+
* [attribute: string]: unknown,
|
|
110
|
+
* }} props
|
|
111
|
+
*/
|
|
112
|
+
export function Link({
|
|
113
|
+
href,
|
|
114
|
+
replace,
|
|
115
|
+
preserveScroll,
|
|
116
|
+
preserveState,
|
|
117
|
+
only,
|
|
118
|
+
except,
|
|
119
|
+
viewTransition,
|
|
120
|
+
instant,
|
|
121
|
+
prefetch,
|
|
122
|
+
cacheFor,
|
|
123
|
+
cacheTags,
|
|
124
|
+
as = 'a',
|
|
125
|
+
onClick,
|
|
126
|
+
onMouseEnter,
|
|
127
|
+
onMouseLeave,
|
|
128
|
+
onMouseDown,
|
|
129
|
+
children,
|
|
130
|
+
...attributes
|
|
131
|
+
}) {
|
|
132
|
+
const router = useContext(RouterContext);
|
|
133
|
+
const [loading, setLoading] = useState(false);
|
|
134
|
+
const modes = modesOf(prefetch);
|
|
135
|
+
const canPrefetch = typeof router?.prefetch === 'function';
|
|
136
|
+
|
|
137
|
+
// The visit usually replaces the page this link is on; its promise
|
|
138
|
+
// settles after the link has gone.
|
|
139
|
+
const live = useRef(true);
|
|
140
|
+
|
|
141
|
+
// Only the latest click's visit clears data-loading: a second click
|
|
142
|
+
// supersedes the first, whose visit then settles while the second is
|
|
143
|
+
// still in flight.
|
|
144
|
+
const latest = useRef(0);
|
|
145
|
+
|
|
146
|
+
/** The pending hover prefetch, if the pointer is resting on the link. */
|
|
147
|
+
const hover = useRef(/** @type {ReturnType<typeof setTimeout> | null} */ (null));
|
|
148
|
+
const cancelHover = () => {
|
|
149
|
+
if (hover.current !== null) clearTimeout(hover.current);
|
|
150
|
+
hover.current = null;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
live.current = true;
|
|
155
|
+
return () => {
|
|
156
|
+
live.current = false;
|
|
157
|
+
cancelHover();
|
|
158
|
+
};
|
|
159
|
+
}, []);
|
|
160
|
+
|
|
161
|
+
// "mount": as soon as the link is on screen. Resolved the way a click
|
|
162
|
+
// would resolve it, against <base>, with the link's own target and
|
|
163
|
+
// download attributes -- a link the router would not follow is not
|
|
164
|
+
// fetched either.
|
|
165
|
+
const onMount = canPrefetch && modes.has('mount');
|
|
166
|
+
const cacheKey = JSON.stringify([cacheFor ?? null, cacheTags ?? null]);
|
|
167
|
+
const { target, download } = attributes;
|
|
168
|
+
useEffect(() => {
|
|
169
|
+
if (!onMount) return;
|
|
170
|
+
const probe = document.createElement('a');
|
|
171
|
+
probe.setAttribute('href', href);
|
|
172
|
+
if (typeof target === 'string') probe.setAttribute('target', target);
|
|
173
|
+
if (download !== undefined && download !== false) probe.setAttribute('download', '');
|
|
174
|
+
const url = visitTarget(probe);
|
|
175
|
+
if (url) prefetchUrl(url);
|
|
176
|
+
// cacheFor and cacheTags by value: a literal array is a new object on
|
|
177
|
+
// every render.
|
|
178
|
+
}, [onMount, router, href, target, download, cacheKey]);
|
|
179
|
+
|
|
180
|
+
/** @param {URL} url */
|
|
181
|
+
function prefetchUrl(url) {
|
|
182
|
+
// A prefetch is a guess. One that fails is a visit that asks the server
|
|
183
|
+
// itself, never an error to report from a hover.
|
|
184
|
+
router.prefetch(url.href, { cacheFor, cacheTags }).catch(() => {});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** @param {HTMLAnchorElement} anchor */
|
|
188
|
+
function prefetchAnchor(anchor) {
|
|
189
|
+
const url = visitTarget(anchor);
|
|
190
|
+
if (url) prefetchUrl(url);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function handleMouseEnter(event) {
|
|
194
|
+
onMouseEnter?.(event);
|
|
195
|
+
if (!canPrefetch || !modes.has('hover')) return;
|
|
196
|
+
|
|
197
|
+
const anchor = event.currentTarget;
|
|
198
|
+
cancelHover();
|
|
199
|
+
hover.current = setTimeout(() => {
|
|
200
|
+
hover.current = null;
|
|
201
|
+
prefetchAnchor(anchor);
|
|
202
|
+
}, HOVER_DELAY_MS);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function handleMouseLeave(event) {
|
|
206
|
+
onMouseLeave?.(event);
|
|
207
|
+
cancelHover();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function handleMouseDown(event) {
|
|
211
|
+
onMouseDown?.(event);
|
|
212
|
+
if (!canPrefetch || !modes.has('click')) return;
|
|
213
|
+
|
|
214
|
+
// "click" is the press of the click that is about to follow -- so not a
|
|
215
|
+
// middle or modified press, which opens a tab the router never sees.
|
|
216
|
+
const native = event.nativeEvent ?? event;
|
|
217
|
+
if (native.button !== 0 || native.metaKey || native.ctrlKey || native.shiftKey || native.altKey) return;
|
|
218
|
+
prefetchAnchor(event.currentTarget);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function handleClick(event) {
|
|
222
|
+
// The app's own handler runs first and may claim the click.
|
|
223
|
+
onClick?.(event);
|
|
224
|
+
|
|
225
|
+
if (router === null) return;
|
|
226
|
+
if (!shouldIntercept(event.nativeEvent, event.currentTarget)) return;
|
|
227
|
+
// A link to an endpoint -- a receipt, a JSON reading -- is no page:
|
|
228
|
+
// the browser follows it, as it would with no router at all (ADR 0074).
|
|
229
|
+
if (isEndpointLink(router, event.currentTarget)) return;
|
|
230
|
+
|
|
231
|
+
// The visit is happening now; a hover prefetch due after it is not.
|
|
232
|
+
cancelHover();
|
|
233
|
+
|
|
234
|
+
event.preventDefault();
|
|
235
|
+
// The URL the browser would have gone to -- resolved against <base>, as
|
|
236
|
+
// the click rules resolved it -- not the raw prop, which the router
|
|
237
|
+
// would resolve against the address bar instead. `only` and `except`
|
|
238
|
+
// make it a partial reload; a malformed path throws here, at the link
|
|
239
|
+
// that names it, before the link shows as loading.
|
|
240
|
+
const visiting = router.visit(event.currentTarget.href, {
|
|
241
|
+
replace,
|
|
242
|
+
preserveScroll,
|
|
243
|
+
preserveState,
|
|
244
|
+
viewTransition,
|
|
245
|
+
...(only !== undefined ? { only } : {}),
|
|
246
|
+
...(except !== undefined ? { except } : {}),
|
|
247
|
+
...(instant !== undefined && instant !== false ? { instant } : {}),
|
|
248
|
+
});
|
|
249
|
+
setLoading(true);
|
|
250
|
+
const click = ++latest.current;
|
|
251
|
+
visiting
|
|
252
|
+
// visit() reports failures as events and is not meant to reject; if it
|
|
253
|
+
// ever does, the link still stops loading rather than leaking an
|
|
254
|
+
// unhandled rejection.
|
|
255
|
+
.catch(() => {})
|
|
256
|
+
.finally(() => {
|
|
257
|
+
if (live.current && click === latest.current) setLoading(false);
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return createElement(
|
|
262
|
+
as,
|
|
263
|
+
{
|
|
264
|
+
...attributes,
|
|
265
|
+
href,
|
|
266
|
+
onClick: handleClick,
|
|
267
|
+
onMouseEnter: handleMouseEnter,
|
|
268
|
+
onMouseLeave: handleMouseLeave,
|
|
269
|
+
onMouseDown: handleMouseDown,
|
|
270
|
+
'data-loading': loading ? '' : undefined,
|
|
271
|
+
},
|
|
272
|
+
children,
|
|
273
|
+
);
|
|
274
|
+
}
|
package/src/live.d.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/** Live updates: the page and its <Deferred> boundaries, fetched again when what they read changes. */
|
|
2
|
+
|
|
3
|
+
import type { Router } from './router.js';
|
|
4
|
+
|
|
5
|
+
export declare const SUBSCRIBE_PATH: '/_live/subscribe';
|
|
6
|
+
export declare const LIVE_BLOCK_ID: 'tinvar-live';
|
|
7
|
+
export declare const DEBOUNCE_MS: number;
|
|
8
|
+
export declare const MAX_WAIT_MS: number;
|
|
9
|
+
export declare const RECONNECT_MS: number;
|
|
10
|
+
export declare const MAX_RECONNECT_MS: number;
|
|
11
|
+
export declare const STABLE_MS: number;
|
|
12
|
+
export declare const RENEW_BEFORE_MS: number;
|
|
13
|
+
export declare const MAX_TOKENS: number;
|
|
14
|
+
export declare const TRADE_TIMEOUT_MS: number;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Listens for changes to what a boundary's render read; `refresh` is called
|
|
18
|
+
* when one of its tags is invalidated. Returns the unsubscribe.
|
|
19
|
+
*/
|
|
20
|
+
export declare function subscribeLive(token: string | null | undefined, refresh: () => void): () => void;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Listens to a channel with the token its subscription was answered with;
|
|
24
|
+
* `message` is called with each message, and `gap` after a gap in the
|
|
25
|
+
* stream, when messages may have been missed. `renew` asks for the
|
|
26
|
+
* subscription again when its token ran out unrenewed, answering with the
|
|
27
|
+
* new token or null. Returns the unsubscribe.
|
|
28
|
+
*/
|
|
29
|
+
export declare function subscribeChannel(
|
|
30
|
+
token: string | null | undefined,
|
|
31
|
+
message: (message: unknown) => void,
|
|
32
|
+
gap?: () => void,
|
|
33
|
+
renew?: () => Promise<string | null>,
|
|
34
|
+
): () => void;
|
|
35
|
+
|
|
36
|
+
/** The live token at the end of a document, or null. */
|
|
37
|
+
export declare function readLiveToken(doc?: Document): string | null;
|
|
38
|
+
|
|
39
|
+
/** The topics a live token names, each with the segments that read it ('*': the page as a whole). */
|
|
40
|
+
export declare function topicsOf(token: string): Map<string, string[]>;
|
|
41
|
+
|
|
42
|
+
export interface LiveOptions {
|
|
43
|
+
router: Pick<Router, 'on' | 'reload' | 'routeTokens' | 'flushByCacheTags' | 'revoke'>;
|
|
44
|
+
fetch?: typeof fetch;
|
|
45
|
+
document?: Document;
|
|
46
|
+
/** The full page load a 401 calls for. */
|
|
47
|
+
hardNavigate?: (url: string) => void;
|
|
48
|
+
now?: () => number;
|
|
49
|
+
random?: () => number;
|
|
50
|
+
/** The first page's live token; read from the document if omitted. */
|
|
51
|
+
initialToken?: string | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface Live {
|
|
55
|
+
start(): void;
|
|
56
|
+
stop(): void;
|
|
57
|
+
/** Drops every token held and closes the stream: client-held data. */
|
|
58
|
+
purge(): void;
|
|
59
|
+
/** Whether a stream is open on the hub. */
|
|
60
|
+
readonly connected: boolean;
|
|
61
|
+
/** The topics the open stream was granted, space-separated. */
|
|
62
|
+
readonly listening: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export declare function createLive(options: LiveOptions): Live;
|