flamefront 0.1.0-alpha.0 → 0.1.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.
@@ -0,0 +1,656 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ import {
4
+ Hydrate,
5
+ createContext,
6
+ useContext,
7
+ useEffect,
8
+ useLayoutEffect,
9
+ useMemo,
10
+ useRef,
11
+ } from "octane"
12
+ import {
13
+ condition,
14
+ idle,
15
+ interaction,
16
+ media,
17
+ never,
18
+ visible,
19
+ } from "octane/hydration"
20
+ import type { Context } from "octane"
21
+ import {
22
+ UNSAFE_DataRouterContext,
23
+ UNSAFE_DataRouterStateContext,
24
+ UNSAFE_FetchersContext,
25
+ UNSAFE_LocationContext,
26
+ UNSAFE_NavigationContext,
27
+ UNSAFE_RouteContext,
28
+ UNSAFE_ViewTransitionContext,
29
+ } from "@octanejs/remix-router"
30
+ import type { GeneratedRouteMetadata, HydrationMode } from "./index.ts"
31
+
32
+ import {
33
+ getRouteFragment,
34
+ shouldHydrateRouteFragment,
35
+ type RouteFragmentCachePolicy,
36
+ type RouteFragmentRoutingOptions,
37
+ } from "./fragment-client.ts"
38
+
39
+ export {
40
+ assertRouteFragmentArtifact,
41
+ getRouteFragment,
42
+ isRouteFragmentArtifact,
43
+ loadRouteFragment,
44
+ prefetchRouteFragment,
45
+ shouldHydrateRouteFragment,
46
+ routeFragmentProtocol,
47
+ } from "./fragment-client.ts"
48
+ export type {
49
+ RouteFragmentArtifact,
50
+ RouteFragmentBoundary,
51
+ RouteFragmentCachePolicy,
52
+ RouteFragmentLoadOptions,
53
+ RouteFragmentRoutingOptions,
54
+ } from "./fragment-client.ts"
55
+
56
+ export interface RouteFragmentRouteOptions {
57
+ readonly metadata: Pick<
58
+ GeneratedRouteMetadata,
59
+ "id" | "boundary" | "kind" | "parent" | "path" | "hydration"
60
+ >
61
+ readonly routing: RouteFragmentRoutingOptions
62
+ readonly policy: RouteFragmentCachePolicy
63
+ /** Used only for initial document hydration and post-insertion hydration. */
64
+ readonly fallbackComponent: RenderableComponent
65
+ }
66
+
67
+ type RenderableComponent<Props = Record<string, unknown>> = (
68
+ props: Props,
69
+ ) => unknown
70
+
71
+ type ContextValue<Value> = Value extends Context<infer Result> ? Result : never
72
+
73
+ const serverEnvironment = typeof document === "undefined"
74
+
75
+ const rootSlot = Symbol.for("flamefront:route-fragment:root")
76
+ const hydrationSlot = Symbol.for("flamefront:route-fragment:hydrate")
77
+ const hostSlot = Symbol.for("flamefront:route-fragment:host")
78
+ const initialRouteSlot = Symbol.for("flamefront:route-fragment:initial")
79
+ const shellRootSlot = Symbol.for("flamefront:shell-outlet:root")
80
+ const shellBridgeSlot = Symbol.for("flamefront:shell-outlet:bridge")
81
+ const shellBridgeComponentSlot = Symbol.for("flamefront:shell-outlet:component")
82
+ const shellRevisionSlot = Symbol.for("flamefront:shell-outlet:revision")
83
+ const shellHostSlot = Symbol.for("flamefront:shell-outlet:host")
84
+ const shellHtmlSlot = Symbol.for("flamefront:shell-outlet:html")
85
+ const shellSetupSlot = Symbol.for("flamefront:shell-outlet:setup")
86
+ const shellUpdateSlot = Symbol.for("flamefront:shell-outlet:update")
87
+ const shellCleanupSlot = Symbol.for("flamefront:shell-outlet:cleanup")
88
+ const shellLocationSlot = Symbol.for("flamefront:shell-outlet:location")
89
+ const shellErrorSlot = Symbol.for("flamefront:shell-outlet:error")
90
+
91
+ /** Server fragment renders omit the selected boundary's outer host element. */
92
+ export const routeFragmentBoundaryTarget = createContext<string | null>(null)
93
+
94
+ /** Server-only HTML for the independently-owned routed outlet. */
95
+ export const routeOutletHtmlContext = createContext<string | null>(null)
96
+
97
+ function boundaryProps(
98
+ metadata: Pick<GeneratedRouteMetadata, "boundary" | "kind">,
99
+ ): Record<string, unknown> {
100
+ return {
101
+ "data-flamefront-boundary": metadata.boundary,
102
+ "data-flamefront-boundary-kind": metadata.kind,
103
+ style: "display:contents",
104
+ }
105
+ }
106
+
107
+ interface NestedOutletRoot {
108
+ unmount(): void
109
+ readonly render?: (
110
+ Component: RenderableComponent,
111
+ props?: Record<string, unknown>,
112
+ ) => void
113
+ }
114
+
115
+ interface ContextBridgeState {
116
+ component: RenderableComponent
117
+ contexts: ContextBridgeContexts
118
+ }
119
+
120
+ interface ClientOutletProps {
121
+ readonly dangerouslySetInnerHTML: { readonly __html: string }
122
+ readonly suppressHydrationWarning: true
123
+ }
124
+
125
+ const emptyOutletProps = Object.freeze({})
126
+ const routeOutletHostPropsSlot = Symbol.for("flamefront:shell-outlet:props")
127
+
128
+ interface ContextBridgeContexts {
129
+ readonly dataRouter: ContextValue<typeof UNSAFE_DataRouterContext>
130
+ readonly dataRouterState: ContextValue<typeof UNSAFE_DataRouterStateContext>
131
+ readonly fetchers: ContextValue<typeof UNSAFE_FetchersContext>
132
+ readonly location: ContextValue<typeof UNSAFE_LocationContext>
133
+ readonly navigation: ContextValue<typeof UNSAFE_NavigationContext>
134
+ readonly route: ContextValue<typeof UNSAFE_RouteContext>
135
+ readonly viewTransition: ContextValue<typeof UNSAFE_ViewTransitionContext>
136
+ }
137
+
138
+ function shellOutletHost(boundary: string): HTMLDivElement | null {
139
+ if (typeof document === "undefined") {
140
+ return null
141
+ }
142
+
143
+ for (const candidate of document.querySelectorAll<HTMLElement>(
144
+ '[data-flamefront-region="outlet"]',
145
+ )) {
146
+ const owner = candidate.closest("[data-flamefront-boundary]")
147
+
148
+ if (owner?.getAttribute("data-flamefront-boundary") === boundary) {
149
+ return candidate as HTMLDivElement
150
+ }
151
+ }
152
+
153
+ return null
154
+ }
155
+
156
+ function shellOutletHtml(boundary: string): string {
157
+ return shellOutletHost(boundary)?.innerHTML ?? ""
158
+ }
159
+
160
+ function shellHydrationBoundary(
161
+ hydration: HydrationMode,
162
+ children: unknown,
163
+ deferredActivation = false,
164
+ ): unknown {
165
+ if (hydration === "full") {
166
+ return children
167
+ }
168
+
169
+ if (hydration === "deferred") {
170
+ // A deferred shell is router-aware but dormant until the first location
171
+ // change; this is intentionally a condition trigger, not idle hydration.
172
+ return <Hydrate when={condition(deferredActivation)}>{children}</Hydrate>
173
+ }
174
+
175
+ if (hydration === "none") {
176
+ return <Hydrate when={never()}>{children}</Hydrate>
177
+ }
178
+
179
+ switch (hydration.when) {
180
+ case "idle":
181
+ return (
182
+ <Hydrate when={idle({ timeout: hydration.timeout })}>
183
+ {children}
184
+ </Hydrate>
185
+ )
186
+ case "visible":
187
+ return (
188
+ <Hydrate
189
+ when={visible({
190
+ rootMargin: hydration.rootMargin,
191
+ threshold:
192
+ hydration.threshold === undefined
193
+ ? undefined
194
+ : [
195
+ ...(Array.isArray(hydration.threshold)
196
+ ? hydration.threshold
197
+ : [hydration.threshold]),
198
+ ],
199
+ })}
200
+ >
201
+ {children}
202
+ </Hydrate>
203
+ )
204
+ case "interaction":
205
+ return (
206
+ <Hydrate when={interaction({ events: hydration.events })}>
207
+ {children}
208
+ </Hydrate>
209
+ )
210
+ case "media":
211
+ return <Hydrate when={media(hydration.query)}>{children}</Hydrate>
212
+ }
213
+ }
214
+
215
+ function RouteOutletHost(props: {
216
+ readonly initialHtml: string
217
+ readonly serverOutlet: unknown
218
+ }): unknown {
219
+ const serverOutletHtml = useContext(routeOutletHtmlContext)
220
+ const attributes = {
221
+ "data-flamefront-region": "outlet",
222
+ "data-flamefront-outlet": "true",
223
+ style: "display:contents",
224
+ }
225
+
226
+ const clientProps = useMemo<ClientOutletProps | typeof emptyOutletProps>(
227
+ () =>
228
+ !serverEnvironment && props.initialHtml !== ""
229
+ ? Object.freeze({
230
+ dangerouslySetInnerHTML: Object.freeze({
231
+ __html: props.initialHtml,
232
+ }),
233
+ suppressHydrationWarning: true,
234
+ })
235
+ : emptyOutletProps,
236
+ [props.initialHtml],
237
+ routeOutletHostPropsSlot,
238
+ )
239
+
240
+ return (
241
+ <div {...attributes} {...clientProps}>
242
+ {serverEnvironment ? (
243
+ <div data-flamefront-outlet-root="true">
244
+ <div
245
+ data-flamefront-outlet-content="true"
246
+ {...(serverOutletHtml === null
247
+ ? {}
248
+ : { dangerouslySetInnerHTML: { __html: serverOutletHtml } })}
249
+ >
250
+ {serverOutletHtml === null ? props.serverOutlet : null}
251
+ </div>
252
+ </div>
253
+ ) : null}
254
+ </div>
255
+ )
256
+ }
257
+
258
+ function createShellBoundary(
259
+ Component: RenderableComponent,
260
+ metadata: Pick<GeneratedRouteMetadata, "boundary" | "kind" | "hydration">,
261
+ ): (props: Record<string, unknown>) => unknown {
262
+ return function ShellBoundary(props) {
263
+ const dataRouter = useContext(UNSAFE_DataRouterContext)
264
+ const dataRouterState = useContext(UNSAFE_DataRouterStateContext)
265
+ const fetchers = useContext(UNSAFE_FetchersContext)
266
+ const location = useContext(UNSAFE_LocationContext)
267
+ const navigation = useContext(UNSAFE_NavigationContext)
268
+ const route = useContext(UNSAFE_RouteContext)
269
+ const viewTransition = useContext(UNSAFE_ViewTransitionContext)
270
+ const rootRef = useRef<NestedOutletRoot | null>(null, shellRootSlot)
271
+ const hostRef = useRef<HTMLDivElement | null>(null, shellHostSlot)
272
+ const bridgeStateRef = useRef<ContextBridgeState | null>(
273
+ null,
274
+ shellBridgeSlot,
275
+ )
276
+ const bridgeRef = useRef<RenderableComponent | null>(
277
+ null,
278
+ shellBridgeComponentSlot,
279
+ )
280
+ const revisionRef = useRef(0, shellRevisionSlot)
281
+ const initialHtmlRef = useRef<string | null>(null, shellHtmlSlot)
282
+ const initialLocationRef = useRef<string | null>(null, shellLocationSlot)
283
+ const initialErrorRef = useRef<boolean | null>(null, shellErrorSlot)
284
+
285
+ const currentLocation = location?.location
286
+ const locationIdentity = currentLocation
287
+ ? `${currentLocation.pathname}${currentLocation.search}${currentLocation.hash}:${currentLocation.key}`
288
+ : ""
289
+
290
+ if (initialLocationRef.current === null) {
291
+ initialLocationRef.current = locationIdentity
292
+ }
293
+
294
+ if (initialHtmlRef.current === null) {
295
+ initialHtmlRef.current = shellOutletHtml(metadata.boundary)
296
+ }
297
+
298
+ if (initialErrorRef.current === null) {
299
+ initialErrorRef.current = dataRouterState?.errors != null
300
+ }
301
+
302
+ const routerErrors = dataRouterState?.errors
303
+ const routerErrorIdentity = routerErrors
304
+ ? Object.keys(routerErrors).join("\u0000")
305
+ : ""
306
+
307
+ if (bridgeStateRef.current === null) {
308
+ const state = {
309
+ component: (() => null) as RenderableComponent,
310
+ contexts: {
311
+ dataRouter,
312
+ dataRouterState,
313
+ fetchers,
314
+ location,
315
+ navigation,
316
+ route,
317
+ viewTransition,
318
+ },
319
+ }
320
+
321
+ state.component = () => state.contexts.route.outlet
322
+ bridgeStateRef.current = state
323
+ } else {
324
+ bridgeStateRef.current.contexts = {
325
+ dataRouter,
326
+ dataRouterState,
327
+ fetchers,
328
+ location,
329
+ navigation,
330
+ route,
331
+ viewTransition,
332
+ }
333
+ }
334
+
335
+ bridgeRef.current ??= createContextBridge(
336
+ bridgeStateRef as {
337
+ current: ContextBridgeState
338
+ },
339
+ )
340
+ revisionRef.current += 1
341
+ const bridge = bridgeRef.current
342
+ const revision = revisionRef.current
343
+
344
+ useLayoutEffect(
345
+ () => {
346
+ if (import.meta.env.SSR) {
347
+ return
348
+ }
349
+
350
+ const host = shellOutletHost(metadata.boundary)
351
+
352
+ if (!host) {
353
+ return
354
+ }
355
+
356
+ // The outer router can render the default error element without
357
+ // Remix's private RouteErrorContext provider. Keep a server-rendered
358
+ // initial error opaque until the router leaves the error location;
359
+ // replacing it from the nested root would turn its payload into null.
360
+ if (
361
+ initialErrorRef.current &&
362
+ initialHtmlRef.current !== "" &&
363
+ rootRef.current === null &&
364
+ routerErrorIdentity !== ""
365
+ ) {
366
+ return
367
+ }
368
+
369
+ const outletRoot =
370
+ host.querySelector<HTMLDivElement>(
371
+ '[data-flamefront-outlet-root="true"]',
372
+ ) ?? host
373
+ const outletContent =
374
+ outletRoot.querySelector<HTMLDivElement>(
375
+ '[data-flamefront-outlet-content="true"]',
376
+ ) ?? outletRoot
377
+
378
+ hostRef.current = outletContent
379
+ let active = true
380
+
381
+ void import("./fragment-hydration-client.tsx").then(
382
+ ({ renderRouteOutlet }) => {
383
+ if (!active || hostRef.current !== outletContent) {
384
+ return
385
+ }
386
+
387
+ if (rootRef.current) {
388
+ rootRef.current.render?.(bridge, {
389
+ revision: revisionRef.current,
390
+ })
391
+ return
392
+ }
393
+
394
+ rootRef.current = renderRouteOutlet(
395
+ outletContent,
396
+ bridge,
397
+ outletContent.hasChildNodes() && routerErrors == null,
398
+ )
399
+ },
400
+ )
401
+
402
+ return () => {
403
+ active = false
404
+ }
405
+ },
406
+ [bridge, routerErrorIdentity, routerErrors],
407
+ shellSetupSlot,
408
+ )
409
+
410
+ useEffect(
411
+ () => {
412
+ rootRef.current?.render?.(bridge, {
413
+ revision,
414
+ })
415
+ },
416
+ [bridge, revision],
417
+ shellUpdateSlot,
418
+ )
419
+
420
+ useLayoutEffect(
421
+ () => () => {
422
+ rootRef.current?.unmount()
423
+ rootRef.current = null
424
+ hostRef.current = null
425
+ },
426
+ [],
427
+ shellCleanupSlot,
428
+ )
429
+
430
+ const shellRoute = {
431
+ ...route,
432
+ outlet: (
433
+ <RouteOutletHost
434
+ initialHtml={initialHtmlRef.current ?? ""}
435
+ serverOutlet={bridge}
436
+ />
437
+ ),
438
+ }
439
+ const shell = (
440
+ <UNSAFE_RouteContext.Provider value={shellRoute}>
441
+ <Component {...props} />
442
+ </UNSAFE_RouteContext.Provider>
443
+ )
444
+ const body = shellHydrationBoundary(
445
+ metadata.hydration ?? "full",
446
+ shell,
447
+ locationIdentity !== initialLocationRef.current,
448
+ )
449
+ const target = useContext(routeFragmentBoundaryTarget)
450
+
451
+ return target === metadata.boundary ? (
452
+ body
453
+ ) : (
454
+ <div {...boundaryProps(metadata)}>{body}</div>
455
+ )
456
+ }
457
+ }
458
+
459
+ /** Add a stable DOM boundary around every generated shell/layout/route node. */
460
+ export function createRouteBoundary(
461
+ Component: RenderableComponent,
462
+ metadata: Pick<GeneratedRouteMetadata, "boundary" | "kind" | "hydration">,
463
+ ): (props: Record<string, unknown>) => unknown {
464
+ if (metadata.kind === "shell") {
465
+ return createShellBoundary(Component, metadata)
466
+ }
467
+
468
+ return (props) => {
469
+ const target = useContext(routeFragmentBoundaryTarget)
470
+ const children = <Component {...props} />
471
+
472
+ return target === metadata.boundary ? (
473
+ children
474
+ ) : (
475
+ <div {...boundaryProps(metadata)}>{children}</div>
476
+ )
477
+ }
478
+ }
479
+
480
+ function createContextBridge(stateRef: {
481
+ readonly current: ContextBridgeState
482
+ }): (props: Record<string, unknown>) => unknown {
483
+ const DataRouterProvider = UNSAFE_DataRouterContext.Provider
484
+ const DataRouterStateProvider = UNSAFE_DataRouterStateContext.Provider
485
+ const FetchersProvider = UNSAFE_FetchersContext.Provider
486
+ const LocationProvider = UNSAFE_LocationContext.Provider
487
+ const NavigationProvider = UNSAFE_NavigationContext.Provider
488
+ const RouteProvider = UNSAFE_RouteContext.Provider
489
+ const ViewTransitionProvider = UNSAFE_ViewTransitionContext.Provider
490
+
491
+ return () => {
492
+ const { component, contexts } = stateRef.current
493
+ const Component = component
494
+
495
+ return (
496
+ <DataRouterProvider value={contexts.dataRouter}>
497
+ <DataRouterStateProvider value={contexts.dataRouterState}>
498
+ <FetchersProvider value={contexts.fetchers}>
499
+ <LocationProvider value={contexts.location}>
500
+ <NavigationProvider value={contexts.navigation}>
501
+ <RouteProvider value={contexts.route}>
502
+ <ViewTransitionProvider value={contexts.viewTransition}>
503
+ <Component />
504
+ </ViewTransitionProvider>
505
+ </RouteProvider>
506
+ </NavigationProvider>
507
+ </LocationProvider>
508
+ </FetchersProvider>
509
+ </DataRouterStateProvider>
510
+ </DataRouterProvider>
511
+ )
512
+ }
513
+ }
514
+
515
+ function routeLocationUrl(): string {
516
+ const location = useContext(UNSAFE_LocationContext)
517
+ const currentLocation = location.location
518
+
519
+ return `${currentLocation.pathname}${currentLocation.search}${currentLocation.hash}`
520
+ }
521
+
522
+ function unmountNestedRoot(root: { unmount(): void } | null): void {
523
+ root?.unmount()
524
+ }
525
+
526
+ function hasServerBoundary(boundary: string): boolean {
527
+ if (typeof document === "undefined") {
528
+ return false
529
+ }
530
+
531
+ return Boolean(
532
+ document.querySelector(`[data-flamefront-boundary="${boundary}"]`),
533
+ )
534
+ }
535
+
536
+ /**
537
+ * Route component used by generated browser routes. Navigation renders the
538
+ * fetched artifact into a boundary first; only the later layout effect may
539
+ * hydrate that boundary. The fallback component is used for direct-document
540
+ * hydration when no fragment was fetched by the browser router.
541
+ */
542
+ export function createRouteFragmentRoute(
543
+ options: RouteFragmentRouteOptions,
544
+ ): (props: Record<string, unknown>) => unknown {
545
+ const fallbackComponent = options.fallbackComponent
546
+ const basename = options.routing.basename ?? "/"
547
+
548
+ return function RouteFragmentRoute(props) {
549
+ const FallbackComponent = fallbackComponent
550
+ const routeUrl = routeLocationUrl()
551
+ const loadedArtifact = getRouteFragment(routeUrl, { basename })
552
+ const hostRef = useRef<HTMLDivElement | null>(null, hostSlot)
553
+ const initialServerUrlRef = useRef<string | null>(null, initialRouteSlot)
554
+ const hasServerMarkup = hasServerBoundary(options.metadata.boundary)
555
+
556
+ if (initialServerUrlRef.current === null && hasServerMarkup) {
557
+ initialServerUrlRef.current = routeUrl
558
+ }
559
+
560
+ const isInitialServerRoute = initialServerUrlRef.current === routeUrl
561
+ const artifact = isInitialServerRoute ? undefined : loadedArtifact
562
+ const nestedRootRef = useRef<{ unmount(): void } | null>(null, rootSlot)
563
+ const dataRouter = useContext(UNSAFE_DataRouterContext)
564
+ const dataRouterState = useContext(UNSAFE_DataRouterStateContext)
565
+ const fetchers = useContext(UNSAFE_FetchersContext)
566
+ const location = useContext(UNSAFE_LocationContext)
567
+ const navigation = useContext(UNSAFE_NavigationContext)
568
+ const route = useContext(UNSAFE_RouteContext)
569
+ const viewTransition = useContext(UNSAFE_ViewTransitionContext)
570
+
571
+ useLayoutEffect(
572
+ () => {
573
+ if (
574
+ !artifact ||
575
+ !shouldHydrateRouteFragment(options.metadata.hydration)
576
+ ) {
577
+ return
578
+ }
579
+
580
+ const host = hostRef.current
581
+
582
+ if (!host) {
583
+ return
584
+ }
585
+
586
+ let active = true
587
+ const hydratedComponent = fallbackComponent
588
+ const bridge = createContextBridge({
589
+ current: {
590
+ component: hydratedComponent,
591
+ contexts: {
592
+ dataRouter,
593
+ dataRouterState,
594
+ fetchers,
595
+ location,
596
+ navigation,
597
+ route,
598
+ viewTransition,
599
+ },
600
+ },
601
+ })
602
+
603
+ unmountNestedRoot(nestedRootRef.current)
604
+ nestedRootRef.current = null
605
+
606
+ if (!import.meta.env.SSR) {
607
+ void import("./fragment-hydration-client.tsx").then(
608
+ ({ hydrateRouteFragment }) => {
609
+ if (!active || hostRef.current !== host) {
610
+ return
611
+ }
612
+
613
+ nestedRootRef.current = hydrateRouteFragment(host, bridge)
614
+ },
615
+ )
616
+ }
617
+
618
+ return () => {
619
+ active = false
620
+ unmountNestedRoot(nestedRootRef.current)
621
+ nestedRootRef.current = null
622
+ }
623
+ },
624
+ [
625
+ artifact,
626
+ dataRouter,
627
+ dataRouterState,
628
+ fetchers,
629
+ location,
630
+ navigation,
631
+ route,
632
+ viewTransition,
633
+ ],
634
+ hydrationSlot,
635
+ )
636
+
637
+ return (
638
+ <div
639
+ {...boundaryProps(options.metadata)}
640
+ ref={hostRef}
641
+ {...(artifact
642
+ ? { dangerouslySetInnerHTML: { __html: artifact.html } }
643
+ : isInitialServerRoute
644
+ ? { children: <FallbackComponent {...props} /> }
645
+ : {})}
646
+ />
647
+ )
648
+ }
649
+ }
650
+
651
+ export {
652
+ isRouteFragmentRequest,
653
+ stripFlamefrontProtocolParams,
654
+ stripFlamefrontProtocolRequest,
655
+ withRouteFragmentProtocol,
656
+ } from "./fragment-protocol.ts"