@vidact/start 0.2.0-beta.0 → 0.2.0-beta.10
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/CHANGELOG.md +109 -0
- package/README.md +26 -5
- package/dist/client.d.ts +29 -16
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +352 -165
- package/dist/client.js.map +1 -1
- package/dist/index-client.d.ts +5 -4
- package/dist/index-client.js +4 -4
- package/dist/index.d.ts +5 -4
- package/dist/index.js +4 -4
- package/dist/link-client.d.ts +6 -4
- package/dist/link-client.d.ts.map +1 -1
- package/dist/link-client.js +10 -4
- package/dist/link-client.js.map +1 -1
- package/dist/link-contract.d.ts +12 -7
- package/dist/link-contract.d.ts.map +1 -1
- package/dist/link-contract.js +12 -7
- package/dist/link-contract.js.map +1 -1
- package/dist/link.d.ts +6 -4
- package/dist/link.d.ts.map +1 -1
- package/dist/link.js +8 -4
- package/dist/link.js.map +1 -1
- package/dist/router.d.ts +57 -55
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +150 -157
- package/dist/router.js.map +1 -1
- package/dist/server.d.ts +29 -16
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +159 -100
- package/dist/server.js.map +1 -1
- package/dist/snapshot.d.ts +13 -10
- package/dist/snapshot.d.ts.map +1 -1
- package/dist/snapshot.js +19 -20
- package/dist/snapshot.js.map +1 -1
- package/dist/virtual.d.ts +3 -2
- package/dist/virtual.js +1 -2
- package/dist/vite.d.ts +18 -15
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +144 -171
- package/dist/vite.js.map +1 -1
- package/package.json +9 -8
- package/src/client.ts +577 -0
- package/src/index-client.ts +3 -0
- package/src/index.ts +3 -0
- package/src/link-client.ts +11 -0
- package/src/link-contract.ts +20 -0
- package/src/link.ts +10 -0
- package/src/router.ts +282 -0
- package/src/server.ts +295 -0
- package/src/snapshot.ts +40 -0
- package/src/virtual.ts +5 -0
- package/src/vite.ts +256 -0
- package/dist/index-client.d.ts.map +0 -1
- package/dist/index-client.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/virtual.d.ts.map +0 -1
- package/dist/virtual.js.map +0 -1
package/src/client.ts
ADDED
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
import {
|
|
2
|
+
binding,
|
|
3
|
+
cloneRenderable,
|
|
4
|
+
compiledRoot,
|
|
5
|
+
createComponentRenderable,
|
|
6
|
+
createCompiledScope,
|
|
7
|
+
createCompiledState,
|
|
8
|
+
source,
|
|
9
|
+
type CompiledComponentResult,
|
|
10
|
+
type CompiledRenderValue,
|
|
11
|
+
type CompiledRoot,
|
|
12
|
+
} from '@vidact/runtime'
|
|
13
|
+
import { hydrateRoot } from '@vidact/runtime/hydrate'
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
loadRouteMatches,
|
|
17
|
+
matchRoutes,
|
|
18
|
+
resolveRouteMatches,
|
|
19
|
+
type LoadedRouteMatch,
|
|
20
|
+
type RouteComponentProps,
|
|
21
|
+
type RouteManifest,
|
|
22
|
+
type RouteManifestEntry,
|
|
23
|
+
type StartComponent,
|
|
24
|
+
} from './router.ts'
|
|
25
|
+
import {
|
|
26
|
+
decodeStartSnapshot,
|
|
27
|
+
VIDACT_START_NAVIGATION_HEADER,
|
|
28
|
+
VIDACT_START_SNAPSHOT_MEDIA_TYPE,
|
|
29
|
+
} from './snapshot.ts'
|
|
30
|
+
|
|
31
|
+
const DEFAULT_ROOT_ID = 'vidact-start-root'
|
|
32
|
+
const DEFAULT_SNAPSHOT_ID = 'vidact-start-snapshot'
|
|
33
|
+
/** How long a prefetched snapshot may stand in for a fresh navigation request. */
|
|
34
|
+
const PREFETCH_STALE_TIME = 30_000
|
|
35
|
+
/** Hover must rest on a link this long before it counts as intent to navigate. */
|
|
36
|
+
const PREFETCH_HOVER_DELAY = 50
|
|
37
|
+
|
|
38
|
+
export interface HydrateStartOptions {
|
|
39
|
+
readonly fetch?: typeof globalThis.fetch
|
|
40
|
+
readonly manifest: RouteManifest
|
|
41
|
+
/**
|
|
42
|
+
* Called when hydration could not attach to the server DOM and the root was rendered
|
|
43
|
+
* client-side instead. Defaults to a console warning: a silent recovery looks like a
|
|
44
|
+
* working page that merely flashes, and is far harder to notice than a warning.
|
|
45
|
+
*/
|
|
46
|
+
readonly onRecoverableError?: (error: unknown) => void
|
|
47
|
+
readonly root?: ParentNode
|
|
48
|
+
readonly rootId?: string
|
|
49
|
+
readonly snapshot?: string
|
|
50
|
+
readonly snapshotId?: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface StartNavigateOptions {
|
|
54
|
+
readonly replace?: boolean
|
|
55
|
+
readonly scroll?: boolean
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface StartClient extends CompiledRoot {
|
|
59
|
+
readonly navigate: (to: string | URL, options?: StartNavigateOptions) => Promise<boolean>
|
|
60
|
+
/**
|
|
61
|
+
* Fetches the route's snapshot and code ahead of a navigation. A navigation to the same
|
|
62
|
+
* path and search within 30 seconds uses the prefetched snapshot instead of a new request.
|
|
63
|
+
*/
|
|
64
|
+
readonly prefetch: (to: string | URL) => Promise<void>
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type HistoryMode = 'none' | 'push' | 'replace'
|
|
68
|
+
|
|
69
|
+
type NavigationPayload =
|
|
70
|
+
| { readonly kind: 'document' }
|
|
71
|
+
| { readonly kind: 'snapshot'; readonly text: string }
|
|
72
|
+
|
|
73
|
+
interface PrefetchEntry {
|
|
74
|
+
readonly expires: number
|
|
75
|
+
readonly payload: Promise<NavigationPayload>
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface ClientRouteState {
|
|
79
|
+
readonly components: readonly ClientRouteComponent[]
|
|
80
|
+
readonly requestUrl: string
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface ClientRouteComponent {
|
|
84
|
+
readonly component: StartComponent
|
|
85
|
+
readonly entry: RouteManifestEntry
|
|
86
|
+
readonly loaderData: LoadedRouteMatch['loaderData']
|
|
87
|
+
readonly params: LoadedRouteMatch['params']
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface RetainedClientApplication {
|
|
91
|
+
readonly application: () => CompiledComponentResult
|
|
92
|
+
readonly state: ClientRouteState
|
|
93
|
+
readonly update: (state: ClientRouteState) => void
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const routeComponentIdentities = new WeakMap<RouteManifestEntry, WeakMap<StartComponent, object>>()
|
|
97
|
+
|
|
98
|
+
export async function hydrateStart(options: HydrateStartOptions): Promise<StartClient> {
|
|
99
|
+
const snapshot = decodeStartSnapshot(
|
|
100
|
+
options.snapshot ?? readSnapshot(options.snapshotId ?? DEFAULT_SNAPSHOT_ID),
|
|
101
|
+
)
|
|
102
|
+
const initialUrl = new URL(snapshot.pathname, window.location.href)
|
|
103
|
+
const matches = matchRoutes(options.manifest, initialUrl.pathname)
|
|
104
|
+
if (matches.length === 0) throw new Error(`cannot hydrate unmatched route ${snapshot.pathname}`)
|
|
105
|
+
const request = new Request(initialUrl)
|
|
106
|
+
const loaded = await loadRouteMatches(matches, request, snapshot.loaderData)
|
|
107
|
+
const host =
|
|
108
|
+
options.root ?? document.querySelector(`#${CSS.escape(options.rootId ?? DEFAULT_ROOT_ID)}`)
|
|
109
|
+
if (host === null) throw new Error('Vidact Start hydration root is missing')
|
|
110
|
+
|
|
111
|
+
let application = createClientApplication(loaded, request.url)
|
|
112
|
+
const root = hydrateRoot(host, application.application, {
|
|
113
|
+
onRecoverableError:
|
|
114
|
+
options.onRecoverableError ??
|
|
115
|
+
((error) => console.warn('[vidact/start] hydration recovered by re-rendering:', error)),
|
|
116
|
+
})
|
|
117
|
+
const fetchNavigation = options.fetch ?? window.fetch.bind(window)
|
|
118
|
+
let navigation = 0
|
|
119
|
+
let navigationController: AbortController | undefined
|
|
120
|
+
let disposed = false
|
|
121
|
+
const prefetchController = new AbortController()
|
|
122
|
+
const prefetches = new Map<string, PrefetchEntry>()
|
|
123
|
+
let hoverAnchor: HTMLAnchorElement | undefined
|
|
124
|
+
let hoverTimer: ReturnType<typeof setTimeout> | undefined
|
|
125
|
+
/** The snapshot key of the navigation in flight, which prefetching must not duplicate. */
|
|
126
|
+
let navigatingKey: string | undefined
|
|
127
|
+
|
|
128
|
+
async function requestNavigationPayload(
|
|
129
|
+
target: URL,
|
|
130
|
+
signal: AbortSignal,
|
|
131
|
+
): Promise<NavigationPayload> {
|
|
132
|
+
const response = await fetchNavigation(target, {
|
|
133
|
+
credentials: 'same-origin',
|
|
134
|
+
headers: { [VIDACT_START_NAVIGATION_HEADER]: '1' },
|
|
135
|
+
signal,
|
|
136
|
+
})
|
|
137
|
+
if (
|
|
138
|
+
!response.ok ||
|
|
139
|
+
response.headers.get('content-type')?.split(';', 1)[0] !== VIDACT_START_SNAPSHOT_MEDIA_TYPE
|
|
140
|
+
) {
|
|
141
|
+
return { kind: 'document' }
|
|
142
|
+
}
|
|
143
|
+
return { kind: 'snapshot', text: await response.text() }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function prefetch(to: string | URL): Promise<void> {
|
|
147
|
+
if (disposed) return
|
|
148
|
+
const target = new URL(to, window.location.href)
|
|
149
|
+
if (!isClientNavigationUrl(target) || isCurrentDocument(target)) return
|
|
150
|
+
const targetMatches = matchRoutes(options.manifest, target.pathname)
|
|
151
|
+
if (targetMatches.length === 0) return
|
|
152
|
+
void resolveRouteMatches(targetMatches).catch(() => undefined)
|
|
153
|
+
|
|
154
|
+
const key = prefetchKey(target)
|
|
155
|
+
if (key === navigatingKey) return
|
|
156
|
+
const now = Date.now()
|
|
157
|
+
let entry = prefetches.get(key)
|
|
158
|
+
if (entry === undefined || entry.expires <= now) {
|
|
159
|
+
for (const [cachedKey, cached] of prefetches) {
|
|
160
|
+
if (cached.expires <= now) prefetches.delete(cachedKey)
|
|
161
|
+
}
|
|
162
|
+
const created: PrefetchEntry = {
|
|
163
|
+
expires: now + PREFETCH_STALE_TIME,
|
|
164
|
+
payload: requestNavigationPayload(target, prefetchController.signal),
|
|
165
|
+
}
|
|
166
|
+
created.payload.catch(() => {
|
|
167
|
+
if (prefetches.get(key) === created) prefetches.delete(key)
|
|
168
|
+
})
|
|
169
|
+
prefetches.set(key, created)
|
|
170
|
+
entry = created
|
|
171
|
+
}
|
|
172
|
+
await entry.payload.then(
|
|
173
|
+
() => undefined,
|
|
174
|
+
() => undefined,
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Removes and returns a fresh prefetch so each snapshot backs at most one navigation. */
|
|
179
|
+
function takePrefetch(target: URL): Promise<NavigationPayload> | undefined {
|
|
180
|
+
const key = prefetchKey(target)
|
|
181
|
+
const entry = prefetches.get(key)
|
|
182
|
+
if (entry === undefined) return undefined
|
|
183
|
+
prefetches.delete(key)
|
|
184
|
+
return entry.expires > Date.now() ? entry.payload : undefined
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function navigate(
|
|
188
|
+
to: string | URL,
|
|
189
|
+
navigationOptions: StartNavigateOptions = {},
|
|
190
|
+
): Promise<boolean> {
|
|
191
|
+
return performNavigation(
|
|
192
|
+
new URL(to, window.location.href),
|
|
193
|
+
navigationOptions.replace === true ? 'replace' : 'push',
|
|
194
|
+
navigationOptions.scroll !== false,
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function performNavigation(
|
|
199
|
+
target: URL,
|
|
200
|
+
historyMode: HistoryMode,
|
|
201
|
+
scroll: boolean,
|
|
202
|
+
): Promise<boolean> {
|
|
203
|
+
if (disposed) throw new Error('cannot navigate an unmounted Vidact Start client')
|
|
204
|
+
if (!isClientNavigationUrl(target)) {
|
|
205
|
+
navigateDocument(target, historyMode === 'replace')
|
|
206
|
+
return false
|
|
207
|
+
}
|
|
208
|
+
const nextMatches = matchRoutes(options.manifest, target.pathname)
|
|
209
|
+
if (nextMatches.length === 0) {
|
|
210
|
+
navigateDocument(target, historyMode === 'replace')
|
|
211
|
+
return false
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
navigation += 1
|
|
215
|
+
const attempt = navigation
|
|
216
|
+
navigationController?.abort()
|
|
217
|
+
const controller = new AbortController()
|
|
218
|
+
navigationController = controller
|
|
219
|
+
navigatingKey = prefetchKey(target)
|
|
220
|
+
cancelHover()
|
|
221
|
+
|
|
222
|
+
// Route code downloads while the snapshot request is in flight instead of after it.
|
|
223
|
+
void resolveRouteMatches(nextMatches).catch(() => undefined)
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
const prefetched = await takePrefetch(target)?.catch(() => undefined)
|
|
227
|
+
if (attempt !== navigation) return false
|
|
228
|
+
const payload = prefetched ?? (await requestNavigationPayload(target, controller.signal))
|
|
229
|
+
if (attempt !== navigation) return false
|
|
230
|
+
if (payload.kind === 'document') {
|
|
231
|
+
navigateDocument(target, historyMode === 'replace')
|
|
232
|
+
return false
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const nextSnapshot = decodeStartSnapshot(payload.text)
|
|
236
|
+
const nextUrl = new URL(nextSnapshot.pathname, target.origin)
|
|
237
|
+
// Fragments never reach the server request. Preserve the caller's fragment unless
|
|
238
|
+
// the snapshot explicitly supplies one, including when the server redirects the path.
|
|
239
|
+
if (nextUrl.hash === '') nextUrl.hash = target.hash
|
|
240
|
+
const snapshotMatches = matchRoutes(options.manifest, nextUrl.pathname)
|
|
241
|
+
if (snapshotMatches.length === 0) {
|
|
242
|
+
navigateDocument(target, historyMode === 'replace')
|
|
243
|
+
return false
|
|
244
|
+
}
|
|
245
|
+
const nextRequest = new Request(nextUrl)
|
|
246
|
+
const nextLoaded = await loadRouteMatches(
|
|
247
|
+
snapshotMatches,
|
|
248
|
+
nextRequest,
|
|
249
|
+
nextSnapshot.loaderData,
|
|
250
|
+
)
|
|
251
|
+
if (attempt !== navigation) return false
|
|
252
|
+
|
|
253
|
+
const nextState = createClientRouteState(nextLoaded, nextRequest.url)
|
|
254
|
+
if (sharedRouteComponentCount(application.state, nextState) === 0) {
|
|
255
|
+
const replacement = createClientApplication(nextLoaded, nextRequest.url)
|
|
256
|
+
root.replace(replacement.application)
|
|
257
|
+
application = replacement
|
|
258
|
+
} else {
|
|
259
|
+
application.update(nextState)
|
|
260
|
+
}
|
|
261
|
+
updateHistory(nextUrl, historyMode)
|
|
262
|
+
if (scroll) scrollToLocation(nextUrl)
|
|
263
|
+
return true
|
|
264
|
+
} catch {
|
|
265
|
+
if (controller.signal.aborted || attempt !== navigation) return false
|
|
266
|
+
navigateDocument(target, historyMode === 'replace')
|
|
267
|
+
return false
|
|
268
|
+
} finally {
|
|
269
|
+
if (attempt === navigation) navigatingKey = undefined
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function handleClick(event: MouseEvent): void {
|
|
274
|
+
const anchor = linkForEvent(event)
|
|
275
|
+
if (anchor === undefined) return
|
|
276
|
+
const target = new URL(anchor.href, window.location.href)
|
|
277
|
+
if (
|
|
278
|
+
target.pathname === window.location.pathname &&
|
|
279
|
+
target.search === window.location.search &&
|
|
280
|
+
target.hash !== window.location.hash
|
|
281
|
+
) {
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
event.preventDefault()
|
|
285
|
+
void performNavigation(
|
|
286
|
+
target,
|
|
287
|
+
anchor.hasAttribute('data-vidact-start-replace') ? 'replace' : 'push',
|
|
288
|
+
true,
|
|
289
|
+
)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function handlePopState(): void {
|
|
293
|
+
void performNavigation(new URL(window.location.href), 'none', false)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function prefetchAnchor(anchor: HTMLAnchorElement): void {
|
|
297
|
+
void prefetch(anchor.href)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function cancelHover(): void {
|
|
301
|
+
if (hoverTimer !== undefined) clearTimeout(hoverTimer)
|
|
302
|
+
hoverTimer = undefined
|
|
303
|
+
hoverAnchor = undefined
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function handlePointerOver(event: PointerEvent): void {
|
|
307
|
+
const anchor = prefetchLinkForTarget(event.target)
|
|
308
|
+
if (anchor === undefined || anchor === hoverAnchor || prefersReducedData()) return
|
|
309
|
+
cancelHover()
|
|
310
|
+
hoverAnchor = anchor
|
|
311
|
+
hoverTimer = setTimeout(() => {
|
|
312
|
+
hoverTimer = undefined
|
|
313
|
+
prefetchAnchor(anchor)
|
|
314
|
+
}, PREFETCH_HOVER_DELAY)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function handlePointerOut(event: PointerEvent): void {
|
|
318
|
+
if (hoverAnchor === undefined) return
|
|
319
|
+
const related = event.relatedTarget
|
|
320
|
+
if (related instanceof Node && hoverAnchor.contains(related)) return
|
|
321
|
+
cancelHover()
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function handleFocusIn(event: FocusEvent): void {
|
|
325
|
+
const anchor = prefetchLinkForTarget(event.target)
|
|
326
|
+
if (anchor !== undefined && !prefersReducedData()) prefetchAnchor(anchor)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** A press is a stronger signal than hover, so it prefetches even in data saver mode. */
|
|
330
|
+
function handlePointerDown(event: PointerEvent): void {
|
|
331
|
+
if (event.button !== 0) return
|
|
332
|
+
const anchor = prefetchLinkForTarget(event.target)
|
|
333
|
+
if (anchor !== undefined) prefetchAnchor(anchor)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
document.addEventListener('click', handleClick)
|
|
337
|
+
document.addEventListener('pointerover', handlePointerOver)
|
|
338
|
+
document.addEventListener('pointerout', handlePointerOut)
|
|
339
|
+
document.addEventListener('focusin', handleFocusIn)
|
|
340
|
+
document.addEventListener('pointerdown', handlePointerDown)
|
|
341
|
+
window.addEventListener('popstate', handlePopState)
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
mount: root.mount,
|
|
345
|
+
replace: root.replace,
|
|
346
|
+
navigate,
|
|
347
|
+
prefetch,
|
|
348
|
+
unmount() {
|
|
349
|
+
if (disposed) return
|
|
350
|
+
disposed = true
|
|
351
|
+
navigation += 1
|
|
352
|
+
navigationController?.abort()
|
|
353
|
+
prefetchController.abort()
|
|
354
|
+
prefetches.clear()
|
|
355
|
+
cancelHover()
|
|
356
|
+
document.removeEventListener('click', handleClick)
|
|
357
|
+
document.removeEventListener('pointerover', handlePointerOver)
|
|
358
|
+
document.removeEventListener('pointerout', handlePointerOut)
|
|
359
|
+
document.removeEventListener('focusin', handleFocusIn)
|
|
360
|
+
document.removeEventListener('pointerdown', handlePointerDown)
|
|
361
|
+
window.removeEventListener('popstate', handlePopState)
|
|
362
|
+
root.unmount()
|
|
363
|
+
},
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function createClientApplication(
|
|
368
|
+
matches: readonly LoadedRouteMatch[],
|
|
369
|
+
requestUrl: string,
|
|
370
|
+
): RetainedClientApplication {
|
|
371
|
+
const initialState = createClientRouteState(matches, requestUrl)
|
|
372
|
+
let currentState = initialState
|
|
373
|
+
let updateState: ((state: ClientRouteState) => void) | undefined
|
|
374
|
+
return {
|
|
375
|
+
application: () => {
|
|
376
|
+
const scope = createCompiledScope()
|
|
377
|
+
const routeSource = source(0)
|
|
378
|
+
const state = createCompiledState(scope, routeSource, currentState)
|
|
379
|
+
updateState = (nextState) => {
|
|
380
|
+
const previousState = currentState
|
|
381
|
+
try {
|
|
382
|
+
state.set(nextState)
|
|
383
|
+
currentState = nextState
|
|
384
|
+
} catch (error) {
|
|
385
|
+
try {
|
|
386
|
+
state.set(previousState)
|
|
387
|
+
} catch {
|
|
388
|
+
// Preserve the navigation failure that triggered the rollback.
|
|
389
|
+
}
|
|
390
|
+
currentState = previousState
|
|
391
|
+
throw error
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
const route = createRetainedRouteRenderable(scope, routeSource, state.get, 0)
|
|
395
|
+
return compiledRoot(scope, () => cloneRenderable(route))
|
|
396
|
+
},
|
|
397
|
+
get state() {
|
|
398
|
+
return currentState
|
|
399
|
+
},
|
|
400
|
+
update(nextState) {
|
|
401
|
+
if (updateState === undefined) {
|
|
402
|
+
throw new Error('cannot update a Vidact Start application before it mounts')
|
|
403
|
+
}
|
|
404
|
+
updateState(nextState)
|
|
405
|
+
},
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function createClientRouteState(
|
|
410
|
+
matches: readonly LoadedRouteMatch[],
|
|
411
|
+
requestUrl: string,
|
|
412
|
+
): ClientRouteState {
|
|
413
|
+
return {
|
|
414
|
+
components: matches.flatMap((match) => {
|
|
415
|
+
const component = match.definition.options.component
|
|
416
|
+
return component === undefined
|
|
417
|
+
? []
|
|
418
|
+
: [{ component, entry: match.entry, loaderData: match.loaderData, params: match.params }]
|
|
419
|
+
}),
|
|
420
|
+
requestUrl,
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function createRetainedRouteRenderable(
|
|
425
|
+
scope: ReturnType<typeof createCompiledScope>,
|
|
426
|
+
routeSource: ReturnType<typeof source>,
|
|
427
|
+
readState: () => ClientRouteState,
|
|
428
|
+
index: number,
|
|
429
|
+
): CompiledRenderValue {
|
|
430
|
+
const initial = readState().components[index]
|
|
431
|
+
if (initial === undefined) return undefined
|
|
432
|
+
const identity = routeComponentIdentity(initial.entry, initial.component)
|
|
433
|
+
let lastProps = routeComponentProps(scope, routeSource, readState, index)
|
|
434
|
+
const input = binding(scope, routeSource, () => {
|
|
435
|
+
const current = readState().components[index]
|
|
436
|
+
if (
|
|
437
|
+
current === undefined ||
|
|
438
|
+
routeComponentIdentity(current.entry, current.component) !== identity
|
|
439
|
+
) {
|
|
440
|
+
return lastProps
|
|
441
|
+
}
|
|
442
|
+
lastProps = routeComponentProps(scope, routeSource, readState, index)
|
|
443
|
+
return lastProps
|
|
444
|
+
})
|
|
445
|
+
return createComponentRenderable(initial.component as never, input, identity)
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function routeComponentProps(
|
|
449
|
+
scope: ReturnType<typeof createCompiledScope>,
|
|
450
|
+
routeSource: ReturnType<typeof source>,
|
|
451
|
+
readState: () => ClientRouteState,
|
|
452
|
+
index: number,
|
|
453
|
+
): RouteComponentProps {
|
|
454
|
+
const state = readState()
|
|
455
|
+
const match = state.components[index]!
|
|
456
|
+
return {
|
|
457
|
+
children: createRetainedRouteRenderable(scope, routeSource, readState, index + 1),
|
|
458
|
+
loaderData: match.loaderData,
|
|
459
|
+
params: match.params,
|
|
460
|
+
requestUrl: state.requestUrl,
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function sharedRouteComponentCount(left: ClientRouteState, right: ClientRouteState): number {
|
|
465
|
+
const length = Math.min(left.components.length, right.components.length)
|
|
466
|
+
let index = 0
|
|
467
|
+
while (index < length) {
|
|
468
|
+
const previous = left.components[index]!
|
|
469
|
+
const next = right.components[index]!
|
|
470
|
+
if (previous.entry !== next.entry || previous.component !== next.component) break
|
|
471
|
+
index += 1
|
|
472
|
+
}
|
|
473
|
+
return index
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function routeComponentIdentity(entry: RouteManifestEntry, component: StartComponent): object {
|
|
477
|
+
let componentIdentities = routeComponentIdentities.get(entry)
|
|
478
|
+
if (componentIdentities === undefined) {
|
|
479
|
+
componentIdentities = new WeakMap()
|
|
480
|
+
routeComponentIdentities.set(entry, componentIdentities)
|
|
481
|
+
}
|
|
482
|
+
let identity = componentIdentities.get(component)
|
|
483
|
+
if (identity === undefined) {
|
|
484
|
+
identity = {}
|
|
485
|
+
componentIdentities.set(component, identity)
|
|
486
|
+
}
|
|
487
|
+
return identity
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function linkForEvent(event: MouseEvent): HTMLAnchorElement | undefined {
|
|
491
|
+
if (
|
|
492
|
+
event.defaultPrevented ||
|
|
493
|
+
event.button !== 0 ||
|
|
494
|
+
event.metaKey ||
|
|
495
|
+
event.ctrlKey ||
|
|
496
|
+
event.shiftKey ||
|
|
497
|
+
event.altKey
|
|
498
|
+
) {
|
|
499
|
+
return undefined
|
|
500
|
+
}
|
|
501
|
+
return startLinkForTarget(event.target)
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function prefetchLinkForTarget(target: EventTarget | null): HTMLAnchorElement | undefined {
|
|
505
|
+
const anchor = startLinkForTarget(target)
|
|
506
|
+
return anchor === undefined || anchor.dataset.vidactStartPrefetch === 'none' ? undefined : anchor
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function startLinkForTarget(target: EventTarget | null): HTMLAnchorElement | undefined {
|
|
510
|
+
if (!(target instanceof Element)) return undefined
|
|
511
|
+
const anchor = target.closest<HTMLAnchorElement>('a[data-vidact-start-link]')
|
|
512
|
+
if (
|
|
513
|
+
anchor === null ||
|
|
514
|
+
anchor.hasAttribute('download') ||
|
|
515
|
+
(anchor.target !== '' && anchor.target !== '_self') ||
|
|
516
|
+
anchor.rel.split(/\s+/u).includes('external')
|
|
517
|
+
) {
|
|
518
|
+
return undefined
|
|
519
|
+
}
|
|
520
|
+
const url = new URL(anchor.href, window.location.href)
|
|
521
|
+
return isClientNavigationUrl(url) ? anchor : undefined
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function isClientNavigationUrl(url: URL): boolean {
|
|
525
|
+
return (
|
|
526
|
+
url.origin === window.location.origin && (url.protocol === 'http:' || url.protocol === 'https:')
|
|
527
|
+
)
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function isCurrentDocument(url: URL): boolean {
|
|
531
|
+
return url.pathname === window.location.pathname && url.search === window.location.search
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function prefetchKey(url: URL): string {
|
|
535
|
+
return `${url.pathname}${url.search}`
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function prefersReducedData(): boolean {
|
|
539
|
+
const connection = (navigator as Navigator & { readonly connection?: { saveData?: boolean } })
|
|
540
|
+
.connection
|
|
541
|
+
return connection?.saveData === true
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function updateHistory(url: URL, mode: HistoryMode): void {
|
|
545
|
+
if (mode === 'push') window.history.pushState({}, '', url)
|
|
546
|
+
else if (mode === 'replace') window.history.replaceState(window.history.state, '', url)
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function scrollToLocation(url: URL): void {
|
|
550
|
+
if (url.hash.length > 1) {
|
|
551
|
+
let id: string
|
|
552
|
+
try {
|
|
553
|
+
id = decodeURIComponent(url.hash.slice(1))
|
|
554
|
+
} catch {
|
|
555
|
+
id = url.hash.slice(1)
|
|
556
|
+
}
|
|
557
|
+
const target = document.getElementById(id)
|
|
558
|
+
if (target !== null) {
|
|
559
|
+
target.scrollIntoView()
|
|
560
|
+
return
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
window.scrollTo({ top: 0, left: 0, behavior: 'instant' })
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function navigateDocument(url: URL, replace: boolean): void {
|
|
567
|
+
if (replace) window.location.replace(url)
|
|
568
|
+
else window.location.assign(url)
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function readSnapshot(id: string): string {
|
|
572
|
+
const element = document.querySelector(`#${CSS.escape(id)}`)
|
|
573
|
+
if (element === null) throw new Error(`Vidact Start snapshot ${id} is missing`)
|
|
574
|
+
return element.textContent ?? ''
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
export type { StartComponent }
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { compiledRoot, createNarrowCompiledScope, type CompiledRenderValue } from '@vidact/runtime'
|
|
2
|
+
import { createElement } from '@vidact/runtime/hydrate'
|
|
3
|
+
|
|
4
|
+
import { anchorProps, type LinkProps } from './link-contract.ts'
|
|
5
|
+
|
|
6
|
+
export function Link(props: LinkProps): CompiledRenderValue {
|
|
7
|
+
const scope = createNarrowCompiledScope()
|
|
8
|
+
return compiledRoot(scope, () => createElement('a', anchorProps(props)))
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type { LinkProps }
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { CompiledRenderValue } from '@vidact/runtime'
|
|
2
|
+
|
|
3
|
+
export interface LinkProps extends Readonly<Record<string, unknown>> {
|
|
4
|
+
readonly children?: CompiledRenderValue
|
|
5
|
+
readonly href: string
|
|
6
|
+
/** `intent` (the default) prefetches on hover, focus, or press; `none` waits for the click. */
|
|
7
|
+
readonly prefetch?: 'intent' | 'none'
|
|
8
|
+
readonly reloadDocument?: boolean
|
|
9
|
+
readonly replace?: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function anchorProps(props: LinkProps): Record<string, unknown> {
|
|
13
|
+
const { prefetch = 'intent', reloadDocument = false, replace = false, ...attributes } = props
|
|
14
|
+
return {
|
|
15
|
+
...attributes,
|
|
16
|
+
...(reloadDocument ? {} : { 'data-vidact-start-link': '' }),
|
|
17
|
+
...(replace ? { 'data-vidact-start-replace': '' } : {}),
|
|
18
|
+
...(!reloadDocument && prefetch === 'none' ? { 'data-vidact-start-prefetch': 'none' } : {}),
|
|
19
|
+
}
|
|
20
|
+
}
|
package/src/link.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { CompiledRenderValue } from '@vidact/runtime'
|
|
2
|
+
import { createElement, type ServerChild } from '@vidact/runtime/server'
|
|
3
|
+
|
|
4
|
+
import { anchorProps, type LinkProps } from './link-contract.ts'
|
|
5
|
+
|
|
6
|
+
export function Link(props: LinkProps): CompiledRenderValue {
|
|
7
|
+
return createElement('a', anchorProps(props)) as ServerChild as unknown as CompiledRenderValue
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type { LinkProps }
|