@tinvar/react 0.0.0-stage → 0.4.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.
Files changed (120) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +168 -4
  3. package/src/action-server.js +41 -0
  4. package/src/action.d.ts +68 -0
  5. package/src/action.js +167 -0
  6. package/src/bootstrap.js +153 -0
  7. package/src/channel.d.ts +46 -0
  8. package/src/channel.js +132 -0
  9. package/src/client-modules.d.ts +8 -0
  10. package/src/client-modules.js +88 -0
  11. package/src/client.d.ts +36 -0
  12. package/src/client.js +42 -0
  13. package/src/compose-route.d.ts +33 -0
  14. package/src/compose-route.js +107 -0
  15. package/src/csrf.d.ts +11 -0
  16. package/src/csrf.js +49 -0
  17. package/src/deferred-boundary.d.ts +61 -0
  18. package/src/deferred-boundary.js +646 -0
  19. package/src/deferred-groups.d.ts +24 -0
  20. package/src/deferred-groups.js +198 -0
  21. package/src/deferred-revalidation.d.ts +37 -0
  22. package/src/deferred-revalidation.js +108 -0
  23. package/src/deferred-triggers.d.ts +26 -0
  24. package/src/deferred-triggers.js +172 -0
  25. package/src/deferred.d.ts +24 -0
  26. package/src/deferred.jsx +100 -0
  27. package/src/document-load.js +95 -0
  28. package/src/endpoint.d.ts +87 -0
  29. package/src/endpoint.js +304 -0
  30. package/src/form-data.js +219 -0
  31. package/src/form.d.ts +177 -0
  32. package/src/form.js +615 -0
  33. package/src/forms.d.ts +4 -0
  34. package/src/forms.js +10 -0
  35. package/src/fragment-client.d.ts +70 -0
  36. package/src/fragment-client.js +510 -0
  37. package/src/head.d.ts +85 -0
  38. package/src/head.js +360 -0
  39. package/src/inbox-view.d.ts +17 -0
  40. package/src/inbox-view.js +105 -0
  41. package/src/inbox.d.ts +108 -0
  42. package/src/inbox.js +293 -0
  43. package/src/index.combined.d.ts +31 -0
  44. package/src/index.d.ts +5 -0
  45. package/src/index.js +29 -0
  46. package/src/index.server.d.ts +4 -0
  47. package/src/index.server.js +8 -0
  48. package/src/infinite-scroll.d.ts +108 -0
  49. package/src/infinite-scroll.js +715 -0
  50. package/src/layout-props.d.ts +30 -0
  51. package/src/layout-props.js +143 -0
  52. package/src/link-click.d.ts +3 -0
  53. package/src/link-click.js +68 -0
  54. package/src/link.d.ts +63 -0
  55. package/src/link.js +274 -0
  56. package/src/live.d.ts +65 -0
  57. package/src/live.js +977 -0
  58. package/src/merge-slot.d.ts +41 -0
  59. package/src/merge-slot.js +253 -0
  60. package/src/merge.d.ts +111 -0
  61. package/src/merge.js +149 -0
  62. package/src/once-slot.d.ts +14 -0
  63. package/src/once-slot.js +75 -0
  64. package/src/once-store.d.ts +26 -0
  65. package/src/once-store.js +211 -0
  66. package/src/once.d.ts +44 -0
  67. package/src/once.js +65 -0
  68. package/src/optimistic.d.ts +14 -0
  69. package/src/optimistic.js +159 -0
  70. package/src/optional.d.ts +10 -0
  71. package/src/optional.js +31 -0
  72. package/src/page-root.d.ts +56 -0
  73. package/src/page-root.js +455 -0
  74. package/src/payload.d.ts +15 -0
  75. package/src/payload.js +101 -0
  76. package/src/poll.d.ts +55 -0
  77. package/src/poll.js +224 -0
  78. package/src/precognition.js +190 -0
  79. package/src/prefetch-cache.d.ts +46 -0
  80. package/src/prefetch-cache.js +256 -0
  81. package/src/progress.d.ts +38 -0
  82. package/src/progress.js +243 -0
  83. package/src/remember-store.d.ts +50 -0
  84. package/src/remember-store.js +486 -0
  85. package/src/rescue.d.ts +10 -0
  86. package/src/rescue.js +30 -0
  87. package/src/resumable.js +822 -0
  88. package/src/resume-store.js +364 -0
  89. package/src/route.d.ts +50 -0
  90. package/src/route.js +227 -0
  91. package/src/router-hooks.js +234 -0
  92. package/src/router.d.ts +586 -0
  93. package/src/router.js +3555 -0
  94. package/src/segment.d.ts +36 -0
  95. package/src/segment.js +116 -0
  96. package/src/server.d.ts +71 -0
  97. package/src/server.js +242 -0
  98. package/src/submit.js +333 -0
  99. package/src/telemetry.d.ts +71 -0
  100. package/src/telemetry.js +851 -0
  101. package/src/testing.d.ts +89 -0
  102. package/src/testing.js +634 -0
  103. package/src/transfer.d.ts +48 -0
  104. package/src/transfer.js +135 -0
  105. package/src/transport.js +390 -0
  106. package/src/upload.d.ts +342 -0
  107. package/src/upload.js +1240 -0
  108. package/src/use-channel.d.ts +21 -0
  109. package/src/use-channel.js +51 -0
  110. package/src/use-form.d.ts +86 -0
  111. package/src/use-form.js +432 -0
  112. package/src/use-http.d.ts +56 -0
  113. package/src/use-http.js +248 -0
  114. package/src/use-inbox.d.ts +41 -0
  115. package/src/use-inbox.js +201 -0
  116. package/src/use-remember.d.ts +10 -0
  117. package/src/use-remember.js +132 -0
  118. package/src/when-mounted.d.ts +15 -0
  119. package/src/when-mounted.js +27 -0
  120. 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;