@lovrozagar/flare 0.9.13 → 0.9.15
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/README.md +83 -57
- package/package.json +2 -1
- package/src/form/index.tsx +2 -1
- package/src/link/index.tsx +3 -2
- package/src/logger.ts +6 -3
- package/src/navigation/index.ts +305 -129
- package/src/navigation/transition-scope.ts +41 -0
- package/src/outlet/index.tsx +12 -2
- package/src/outlet/outlet-nodes.ts +26 -0
- package/src/outlet/types.ts +8 -1
- package/src/plugins/virtual.d.ts +0 -5
- package/src/plugins/virtual.ts +1 -6
- package/src/router-config/index.ts +2 -1
- package/src/router.ts +1 -0
- package/src/view-transition-boundary/index.tsx +51 -0
- package/src/view-transition-boundary/registry.ts +22 -0
package/README.md
CHANGED
|
@@ -630,11 +630,36 @@ import { navigate } from "@lovrozagar/flare";
|
|
|
630
630
|
- `prefetch={false}` disables. Default comes from router / route cache.
|
|
631
631
|
- `activeClass` / `inactiveClass` / `activeProps` / `inactiveProps` / `aria-current`.
|
|
632
632
|
- `createRouter({ viewTransitions: true })` wraps updates in `document.startViewTransition` (Chromium). Put `<ViewTransitionCSS />` from `@lovrozagar/flare/view-transition-css` in the root head.
|
|
633
|
+
- `<ViewTransitionBoundary>` from `@lovrozagar/flare/view-transition-boundary` scopes navigations inside a persistent layout to one element, so everything outside it (sidebar, header, tabs) keeps hover, clicks and CSS transitions while the content animates. See [Scoped view transitions](#scoped-view-transitions).
|
|
633
634
|
- `useBlocker(() => dirty())` — first-class leave guard.
|
|
634
635
|
- Optional chrome: `<NavigationProgress />` from `@lovrozagar/flare/navigation-progress`.
|
|
635
636
|
|
|
636
637
|
`ctx.invalidate()` / `router.invalidate()` refetches current matches.
|
|
637
638
|
|
|
639
|
+
### Scoped view transitions
|
|
640
|
+
|
|
641
|
+
```tsx
|
|
642
|
+
import { ViewTransitionBoundary } from "@lovrozagar/flare/view-transition-boundary";
|
|
643
|
+
|
|
644
|
+
export const route = createLayout("_root_/(app)").render((props) => (
|
|
645
|
+
<div class="shell">
|
|
646
|
+
<aside>…sidebar…</aside>
|
|
647
|
+
<ViewTransitionBoundary>
|
|
648
|
+
<main>{props.children}</main>
|
|
649
|
+
</ViewTransitionBoundary>
|
|
650
|
+
</div>
|
|
651
|
+
));
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
- Each navigation starts one view transition, on the innermost boundary element that wraps the content the navigation swaps. A navigation that leaves the layout, an intercept or not-found change, or no boundary uses `document.startViewTransition` as before.
|
|
655
|
+
- The boundary renders its single child unchanged; the child must be one element with a box (not `display: contents` or inline). Several children or a text child throw in dev.
|
|
656
|
+
- `scope: "document"` forces a document transition: `createRouter({ viewTransitions: { scope: "document" } })`, `<Link viewTransition={{ scope: "document" }}>`, `navigate({ viewTransition: { scope } })`, or a function of the location change. Navigation and link options override the router.
|
|
657
|
+
- Element-scoped transitions need Chromium (CSS View Transitions Level 2). Firefox and WebKit fall back to the document transition.
|
|
658
|
+
- With a boundary, selectors change: use bare or element-qualified pseudo-elements (`::view-transition-old(root)`, `main::view-transition-group(root)`). `:root::view-transition-*` and `html:active-view-transition-type(x)` match only document transitions.
|
|
659
|
+
- During the transition the boundary gets `contain: layout`, so `position: fixed` elements inside it are positioned against it for the duration. Render fixed UI in a portal or the top layer.
|
|
660
|
+
- Named elements (`view-transition-name`) outside the boundary do not animate; use `scope: "document"` for a morph across it.
|
|
661
|
+
- In dev, the scope carries `data-flare-vt-scope` while it animates, and a boundary around no route content (a sidebar, or an element inside a page) logs a warning once.
|
|
662
|
+
|
|
638
663
|
## Rewrite
|
|
639
664
|
|
|
640
665
|
Vanity URLs without changing the matched virtual path.
|
|
@@ -1129,63 +1154,64 @@ Do **not** set `flare({ port: 3000 })` in e2e apps — it steals Playwright’s
|
|
|
1129
1154
|
|
|
1130
1155
|
Import features from their path.
|
|
1131
1156
|
|
|
1132
|
-
| Export
|
|
1133
|
-
|
|
|
1134
|
-
| `@lovrozagar/flare`
|
|
1135
|
-
| `@lovrozagar/flare/router`
|
|
1136
|
-
| `@lovrozagar/flare/page`
|
|
1137
|
-
| `@lovrozagar/flare/layout`
|
|
1138
|
-
| `@lovrozagar/flare/root-layout`
|
|
1139
|
-
| `@lovrozagar/flare/path-segment`
|
|
1140
|
-
| `@lovrozagar/flare/link`
|
|
1141
|
-
| `@lovrozagar/flare/outlet`
|
|
1142
|
-
| `@lovrozagar/flare/hydrate`
|
|
1143
|
-
| `@lovrozagar/flare/client`
|
|
1144
|
-
| `@lovrozagar/flare/await`
|
|
1145
|
-
| `@lovrozagar/flare/form`
|
|
1146
|
-
| `@lovrozagar/flare/server-fn`
|
|
1147
|
-
| `@lovrozagar/flare/server-fn-query`
|
|
1148
|
-
| `@lovrozagar/flare/plugins`
|
|
1149
|
-
| `@lovrozagar/flare/styles`
|
|
1150
|
-
| `@lovrozagar/flare/fonts`
|
|
1151
|
-
| `@lovrozagar/flare/fonts/<family>`
|
|
1152
|
-
| `@lovrozagar/flare/image`
|
|
1153
|
-
| `@lovrozagar/flare/theme`
|
|
1154
|
-
| `@lovrozagar/flare/direction`
|
|
1155
|
-
| `@lovrozagar/flare/locale`
|
|
1156
|
-
| `@lovrozagar/flare/i18n`
|
|
1157
|
-
| `@lovrozagar/flare/middleware`
|
|
1158
|
-
| `@lovrozagar/flare/middleware/*`
|
|
1159
|
-
| `@lovrozagar/flare/errors`
|
|
1160
|
-
| `@lovrozagar/flare/security`
|
|
1161
|
-
| `@lovrozagar/flare/revalidation`
|
|
1162
|
-
| `@lovrozagar/flare/store`
|
|
1163
|
-
| `@lovrozagar/flare/store-filesystem`
|
|
1164
|
-
| `@lovrozagar/flare/query-client`
|
|
1165
|
-
| `@lovrozagar/flare/suspense-query`
|
|
1166
|
-
| `@lovrozagar/flare/broadcast`
|
|
1167
|
-
| `@lovrozagar/flare/lazy`
|
|
1168
|
-
| `@lovrozagar/flare/server`
|
|
1169
|
-
| `@lovrozagar/flare/server-context`
|
|
1170
|
-
| `@lovrozagar/flare/fetch-dedupe`
|
|
1171
|
-
| `@lovrozagar/flare/server-only`
|
|
1172
|
-
| `@lovrozagar/flare/client-only`
|
|
1173
|
-
| `@lovrozagar/flare/isomorphic`
|
|
1174
|
-
| `@lovrozagar/flare/testing`
|
|
1175
|
-
| `@lovrozagar/flare/sitemap`
|
|
1176
|
-
| `@lovrozagar/flare/search-engine`
|
|
1177
|
-
| `@lovrozagar/flare/rewrite`
|
|
1178
|
-
| `@lovrozagar/flare/mount`
|
|
1179
|
-
| `@lovrozagar/flare/intercept-outlet`
|
|
1180
|
-
| `@lovrozagar/flare/navigation-progress`
|
|
1181
|
-
| `@lovrozagar/flare/reset-css`
|
|
1182
|
-
| `@lovrozagar/flare/view-transition-
|
|
1183
|
-
| `@lovrozagar/flare/
|
|
1184
|
-
| `@lovrozagar/flare/
|
|
1185
|
-
| `@lovrozagar/flare/
|
|
1186
|
-
| `@lovrozagar/flare/
|
|
1187
|
-
| `@lovrozagar/flare/
|
|
1188
|
-
| `@lovrozagar/flare/
|
|
1157
|
+
| Export | You get |
|
|
1158
|
+
| -------------------------------------------- | --------------------------------------- |
|
|
1159
|
+
| `@lovrozagar/flare` | `createRouter`, hooks, `navigate` |
|
|
1160
|
+
| `@lovrozagar/flare/router` | `createRouter` |
|
|
1161
|
+
| `@lovrozagar/flare/page` | `createPage` |
|
|
1162
|
+
| `@lovrozagar/flare/layout` | `createLayout` |
|
|
1163
|
+
| `@lovrozagar/flare/root-layout` | `createRootLayout` |
|
|
1164
|
+
| `@lovrozagar/flare/path-segment` | `createPathSegment` |
|
|
1165
|
+
| `@lovrozagar/flare/link` | `Link` |
|
|
1166
|
+
| `@lovrozagar/flare/outlet` | `Outlet` |
|
|
1167
|
+
| `@lovrozagar/flare/hydrate` | `hydrate` |
|
|
1168
|
+
| `@lovrozagar/flare/client` | `createClient` |
|
|
1169
|
+
| `@lovrozagar/flare/await` | `<Await>` |
|
|
1170
|
+
| `@lovrozagar/flare/form` | `Form`, `FieldError` |
|
|
1171
|
+
| `@lovrozagar/flare/server-fn` | `createServerFn` |
|
|
1172
|
+
| `@lovrozagar/flare/server-fn-query` | server fn ↔ Query |
|
|
1173
|
+
| `@lovrozagar/flare/plugins` | `flare()` |
|
|
1174
|
+
| `@lovrozagar/flare/styles` | `styles`, `cn`, `compileSx` |
|
|
1175
|
+
| `@lovrozagar/flare/fonts` | `FontCSS`, `createFont` |
|
|
1176
|
+
| `@lovrozagar/flare/fonts/<family>` | `import { inter } from "…/fonts/inter"` |
|
|
1177
|
+
| `@lovrozagar/flare/image` | `Image`, `configureImage` |
|
|
1178
|
+
| `@lovrozagar/flare/theme` | `ThemeScript`, `ThemeProvider` |
|
|
1179
|
+
| `@lovrozagar/flare/direction` | `DirectionScript` |
|
|
1180
|
+
| `@lovrozagar/flare/locale` | `LocaleScript`, `LocaleProvider` |
|
|
1181
|
+
| `@lovrozagar/flare/i18n` | `createTranslations`, `formatMessage` |
|
|
1182
|
+
| `@lovrozagar/flare/middleware` | `onPage`, `virtualPath`, types |
|
|
1183
|
+
| `@lovrozagar/flare/middleware/*` | builtins |
|
|
1184
|
+
| `@lovrozagar/flare/errors` | `NotFoundError`, redirects, auth errors |
|
|
1185
|
+
| `@lovrozagar/flare/security` | `SecurityConfig` |
|
|
1186
|
+
| `@lovrozagar/flare/revalidation` | `createRevalidateFn` |
|
|
1187
|
+
| `@lovrozagar/flare/store` | `FlareStore` |
|
|
1188
|
+
| `@lovrozagar/flare/store-filesystem` | disk store |
|
|
1189
|
+
| `@lovrozagar/flare/query-client` | `createQueryClientGetter` |
|
|
1190
|
+
| `@lovrozagar/flare/suspense-query` | `useSuspenseQuery` |
|
|
1191
|
+
| `@lovrozagar/flare/broadcast` | cross-tab |
|
|
1192
|
+
| `@lovrozagar/flare/lazy` | `lazy`, `clientLazy` |
|
|
1193
|
+
| `@lovrozagar/flare/server` | `createServer` |
|
|
1194
|
+
| `@lovrozagar/flare/server-context` | ALS, `background` |
|
|
1195
|
+
| `@lovrozagar/flare/fetch-dedupe` | `withFetchDedupe` |
|
|
1196
|
+
| `@lovrozagar/flare/server-only` | `createServerOnlyFn` |
|
|
1197
|
+
| `@lovrozagar/flare/client-only` | `createClientOnlyFn` |
|
|
1198
|
+
| `@lovrozagar/flare/isomorphic` | `createIsomorphicFn` |
|
|
1199
|
+
| `@lovrozagar/flare/testing` | Playwright helpers |
|
|
1200
|
+
| `@lovrozagar/flare/sitemap` | sitemap XML |
|
|
1201
|
+
| `@lovrozagar/flare/search-engine` | IndexNow / Google / Bing |
|
|
1202
|
+
| `@lovrozagar/flare/rewrite` | `LocationRewrite` |
|
|
1203
|
+
| `@lovrozagar/flare/mount` | `mount` |
|
|
1204
|
+
| `@lovrozagar/flare/intercept-outlet` | `InterceptOutlet` |
|
|
1205
|
+
| `@lovrozagar/flare/navigation-progress` | `<NavigationProgress>` |
|
|
1206
|
+
| `@lovrozagar/flare/reset-css` | `<ResetCSS>` |
|
|
1207
|
+
| `@lovrozagar/flare/view-transition-boundary` | `<ViewTransitionBoundary>` |
|
|
1208
|
+
| `@lovrozagar/flare/view-transition-css` | `<ViewTransitionCSS>` |
|
|
1209
|
+
| `@lovrozagar/flare/prerender` | `loadPrerenderArtifacts` |
|
|
1210
|
+
| `@lovrozagar/flare/tracing` | timing / OTel |
|
|
1211
|
+
| `@lovrozagar/flare/validation` | `Validator`, `runValidator` |
|
|
1212
|
+
| `@lovrozagar/flare/codegen` | generated types |
|
|
1213
|
+
| `@lovrozagar/flare/generators` | `runGenerate` |
|
|
1214
|
+
| `@lovrozagar/flare/virtual-types` | `/// <reference types="…" />` |
|
|
1189
1215
|
|
|
1190
1216
|
## Repository layout
|
|
1191
1217
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lovrozagar/flare",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.15",
|
|
4
4
|
"description": "Solid meta-framework. Server-driven, NDJSON streaming, renderToStream.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"flare",
|
|
@@ -89,6 +89,7 @@
|
|
|
89
89
|
"./theme": "./src/theme.ts",
|
|
90
90
|
"./tracing": "./src/tracing/index.ts",
|
|
91
91
|
"./validation": "./src/validation/index.ts",
|
|
92
|
+
"./view-transition-boundary": "./src/view-transition-boundary/index.tsx",
|
|
92
93
|
"./view-transition-css": "./src/view-transition-css.ts",
|
|
93
94
|
"./virtual-types": "./src/plugins/virtual.d.ts"
|
|
94
95
|
},
|
package/src/form/index.tsx
CHANGED
|
@@ -50,8 +50,9 @@ interface FormOwnProps<TInput, TOutput> {
|
|
|
50
50
|
onSuccess?: (data: TOutput) => void;
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
/* The element's own `onError` (an ErrorEvent) gives way to the action's. */
|
|
53
54
|
export type FormProps<TInput, TOutput> = FormOwnProps<TInput, TOutput> &
|
|
54
|
-
Omit<JSX.FormHTMLAttributes<HTMLFormElement>, "action" | "children" | "enctype" | "method">;
|
|
55
|
+
Omit<JSX.FormHTMLAttributes<HTMLFormElement>, "action" | "children" | "enctype" | "method" | "onError">;
|
|
55
56
|
|
|
56
57
|
/** Seed `form.error()` after a no-JS PE POST with form-level validation errors. */
|
|
57
58
|
export function seedFormErrorFromSsr(ssrCtx: FormActionContext | undefined): Error | null {
|
package/src/link/index.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ViewTransitionConfig } from "../outlet/types.ts";
|
|
1
2
|
import { createEffect, createMemo, createSignal, omit, Show } from "solid-js";
|
|
2
3
|
import type { JSX } from "@solidjs/web";
|
|
3
4
|
import { applyRewriteOutput, isExternal, navigate, prefetch } from "../navigation/index.ts";
|
|
@@ -27,7 +28,7 @@ type InternalLinkProps<TPath extends RoutePaths = RoutePaths> = FlareAnchorProps
|
|
|
27
28
|
scroll?: boolean;
|
|
28
29
|
shallow?: boolean;
|
|
29
30
|
to: TPath;
|
|
30
|
-
viewTransition?:
|
|
31
|
+
viewTransition?: ViewTransitionConfig;
|
|
31
32
|
} & RouteParamsProps<TPath> &
|
|
32
33
|
RouteSearchProps<TPath>;
|
|
33
34
|
|
|
@@ -64,7 +65,7 @@ interface LinkPropsInternal {
|
|
|
64
65
|
style?: JSX.CSSProperties | string;
|
|
65
66
|
target?: string;
|
|
66
67
|
to?: string;
|
|
67
|
-
viewTransition?:
|
|
68
|
+
viewTransition?: ViewTransitionConfig;
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
const DANGEROUS_PROTOCOLS = ["javascript:", "data:", "blob:", "vbscript:"];
|
package/src/logger.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import defaultLevel from "virtual:flare-log-level";
|
|
2
|
-
|
|
3
1
|
export type LogLevel = "error" | "silent" | "verbose" | "warn";
|
|
4
2
|
|
|
3
|
+
/* A Vite `define` from Flare's plugin (its `logLevel`, else warn in dev and error in production).
|
|
4
|
+
Outside the plugin (a library's unit tests importing `cn`) the logger warns, and needs nothing
|
|
5
|
+
from Flare's build. */
|
|
6
|
+
declare const __FLARE_LOG_LEVEL__: LogLevel | undefined;
|
|
7
|
+
|
|
5
8
|
const PRIORITY: Record<LogLevel, number> = { error: 1, silent: 0, verbose: 3, warn: 2 };
|
|
6
9
|
|
|
7
|
-
const level: LogLevel =
|
|
10
|
+
const level: LogLevel = typeof __FLARE_LOG_LEVEL__ === "string" ? __FLARE_LOG_LEVEL__ : "warn";
|
|
8
11
|
|
|
9
12
|
export function warn(tag: string, msg: string, data?: unknown): void {
|
|
10
13
|
if (PRIORITY[level] < 2) return;
|
package/src/navigation/index.ts
CHANGED
|
@@ -27,7 +27,7 @@ import { isChunkLoadError, isRenderFn } from "../internal.ts";
|
|
|
27
27
|
import { KEEPALIVE_PATH, STORAGE_CHUNK_RELOAD } from "../protocol.ts";
|
|
28
28
|
import type { LocaleConfig } from "../locale.ts";
|
|
29
29
|
import { formatLocaleCookie } from "../locale/cookie.ts";
|
|
30
|
-
import { warn } from "../logger.ts";
|
|
30
|
+
import { verbose, warn } from "../logger.ts";
|
|
31
31
|
import { fetchNDJSON, type NDJSONFetchResult } from "../ndjson-client/index.ts";
|
|
32
32
|
import type { DeferredResolver } from "../state-parser/index.ts";
|
|
33
33
|
import { hasRawDeferredMarkers, hydrateLoaderData } from "../state-parser/index.ts";
|
|
@@ -37,6 +37,7 @@ import type {
|
|
|
37
37
|
LocationChangeInfo,
|
|
38
38
|
ViewTransitionConfig,
|
|
39
39
|
ViewTransitionDirection,
|
|
40
|
+
ViewTransitionScope,
|
|
40
41
|
} from "../outlet/types.ts";
|
|
41
42
|
import { executeRewriteInput, executeRewriteOutput, type LocationRewrite } from "../rewrite/index.ts";
|
|
42
43
|
import type { HeadConfig } from "../route-builder/types.ts";
|
|
@@ -49,6 +50,9 @@ import {
|
|
|
49
50
|
toLocaleMatch,
|
|
50
51
|
} from "../router-primitives/index.ts";
|
|
51
52
|
import { buildUrl, parseSearchParams, type SearchParams, serializeSearchParams } from "../url/index.ts";
|
|
53
|
+
import { allOutletNodes, outletNodes, resetOutletNodes } from "../outlet/outlet-nodes.ts";
|
|
54
|
+
import { registeredBoundaries, resetViewTransitionBoundaries } from "../view-transition-boundary/registry.ts";
|
|
55
|
+
import { resolveTransitionScope } from "./transition-scope.ts";
|
|
52
56
|
import type { LoadedRouteModule, LoadRouteModulesFn } from "./types.ts";
|
|
53
57
|
|
|
54
58
|
function extractRootIdentity(virtualPath: string): string {
|
|
@@ -87,6 +91,38 @@ function hasViewTransitions(doc: Document): doc is Document & ViewTransitionDocu
|
|
|
87
91
|
return "startViewTransition" in doc && typeof doc.startViewTransition === "function";
|
|
88
92
|
}
|
|
89
93
|
|
|
94
|
+
/* Element-scoped view transitions (CSS View Transitions Level 2). Chromium only for now. */
|
|
95
|
+
function hasElementViewTransitions(): boolean {
|
|
96
|
+
return (
|
|
97
|
+
typeof Element !== "undefined" &&
|
|
98
|
+
typeof (Element.prototype as unknown as Partial<ViewTransitionDocument>).startViewTransition === "function"
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* The navigation transition still running, so the next one can skip it first: only a second
|
|
103
|
+
* transition on the same element aborts the first, and two running at once would both freeze
|
|
104
|
+
* content that is being swapped. */
|
|
105
|
+
let activeTransition: ViewTransitionResult | null = null;
|
|
106
|
+
let warnedScopedStart = false;
|
|
107
|
+
const warnedMisplaced = new WeakSet<Element>();
|
|
108
|
+
|
|
109
|
+
/* Dev: a boundary around no route content (a sidebar, a header, a heading inside a page) can never
|
|
110
|
+
* scope a navigation. */
|
|
111
|
+
function warnMisplacedBoundaries(): void {
|
|
112
|
+
const content = allOutletNodes();
|
|
113
|
+
if (content.length === 0) return;
|
|
114
|
+
for (const boundary of registeredBoundaries()) {
|
|
115
|
+
if (warnedMisplaced.has(boundary) || !boundary.isConnected) continue;
|
|
116
|
+
/* Useful only around some outlet's content; a boundary inside a page is swapped with it. */
|
|
117
|
+
if (content.some((node) => node !== boundary && boundary.contains(node))) continue;
|
|
118
|
+
warnedMisplaced.add(boundary);
|
|
119
|
+
warn(
|
|
120
|
+
"nav",
|
|
121
|
+
`<ViewTransitionBoundary> on <${boundary.tagName.toLowerCase()}> wraps no route content, so no navigation can scope to it. Wrap the element that renders the outlet.`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
90
126
|
export type { EffectsConfig, LoadedRouteModule, LoadedRouteModules, LoadRouteModulesFn } from "./types.ts";
|
|
91
127
|
|
|
92
128
|
const GC_INTERVAL = 60_000;
|
|
@@ -580,23 +616,25 @@ function hydrateCachedDeferred(cached: CachedMatch): boolean {
|
|
|
580
616
|
}
|
|
581
617
|
|
|
582
618
|
/**
|
|
583
|
-
*
|
|
584
|
-
* Hydrates prefetch `{ __deferred, key }` markers so Await can track enter `c` chunks.
|
|
619
|
+
* A cached/prefetched shell to paint before the enter NDJSON hop, so a click is not blocked on it.
|
|
620
|
+
* Hydrates prefetch `{ __deferred, key }` markers so Await can track enter `c` chunks. Returns
|
|
621
|
+
* null unless every route is cached; `commit` swaps the route in, so the caller can run it inside
|
|
622
|
+
* a view transition.
|
|
585
623
|
*/
|
|
586
|
-
function
|
|
624
|
+
function prepareCachedShell(
|
|
587
625
|
c: FlareProviderContext,
|
|
588
626
|
allModules: LoadedRouteModule[],
|
|
589
627
|
rootLayout: LoadedRouteModule | undefined,
|
|
590
628
|
search: SearchParams,
|
|
591
629
|
params: Record<string, string | string[]>,
|
|
592
|
-
): {
|
|
593
|
-
if (!ctx) return
|
|
630
|
+
): { commit: () => void; keepMatchIds: string[] } | null {
|
|
631
|
+
if (!ctx) return null;
|
|
594
632
|
/* Paint only a complete shell. A cached layout (hydration seeds it) with an
|
|
595
633
|
* uncached page would mount the page with null loader data. */
|
|
596
634
|
const cachedMatches: CachedMatch[] = [];
|
|
597
635
|
for (const mod of allModules) {
|
|
598
636
|
const cached = ctx.matchCache.get(matchIdForModule(mod, search, params));
|
|
599
|
-
if (!cached || cached.invalid) return
|
|
637
|
+
if (!cached || cached.invalid) return null;
|
|
600
638
|
cachedMatches.push(cached);
|
|
601
639
|
}
|
|
602
640
|
const keepMatchIds: string[] = [];
|
|
@@ -620,14 +658,16 @@ function commitCachedShell(
|
|
|
620
658
|
heads.push({ head, matchId });
|
|
621
659
|
}
|
|
622
660
|
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
661
|
+
const commit = () => {
|
|
662
|
+
c.setIntercepted(null);
|
|
663
|
+
c.setNotFound(false);
|
|
664
|
+
assignMatches(c, buildClientMatches(allModules, search, params));
|
|
665
|
+
c.setParams(params);
|
|
666
|
+
c.setSearch(search);
|
|
667
|
+
syncLocale(params);
|
|
668
|
+
if (heads.length > 0) applyPerRouteHeads(heads);
|
|
669
|
+
};
|
|
670
|
+
return { commit, keepMatchIds };
|
|
631
671
|
}
|
|
632
672
|
|
|
633
673
|
/** Whether the params a route's own path declares kept their values. */
|
|
@@ -713,6 +753,158 @@ function assignMatches(c: FlareProviderContext, next: ReturnType<FlareProviderCo
|
|
|
713
753
|
c.setMatches(next);
|
|
714
754
|
}
|
|
715
755
|
|
|
756
|
+
function locationChange(options: InternalNavigateOptions, url: URL): LocationChangeInfo {
|
|
757
|
+
const direction: ViewTransitionDirection = options._popstateDirection ?? (options._popstate ? "back" : "forward");
|
|
758
|
+
const fromLoc = ctx
|
|
759
|
+
? {
|
|
760
|
+
hash: ctx.location().hash,
|
|
761
|
+
pathname: ctx.location().pathname,
|
|
762
|
+
search: serializeSearchParams(ctx.location().search),
|
|
763
|
+
}
|
|
764
|
+
: null;
|
|
765
|
+
const toLoc = { hash: url.hash, pathname: url.pathname, search: url.search };
|
|
766
|
+
return { direction, fromLocation: fromLoc, pathChanged: fromLoc?.pathname !== toLoc.pathname, toLocation: toLoc };
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/* Scope precedence: navigate()/Link option, then the router default, then "auto". */
|
|
770
|
+
function resolveScope(options: InternalNavigateOptions, info: () => LocationChangeInfo): ViewTransitionScope {
|
|
771
|
+
const fromNav = typeof options.viewTransition === "object" ? options.viewTransition.scope : undefined;
|
|
772
|
+
const fromRouter = typeof defaultViewTransition === "object" ? defaultViewTransition.scope : undefined;
|
|
773
|
+
const scope = fromNav ?? fromRouter ?? "auto";
|
|
774
|
+
return typeof scope === "function" ? scope(info()) : scope;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/** The outlet depth a commit of `allModules` replaces: the first match it does not reuse. */
|
|
778
|
+
function changedDepth(allModules: LoadedRouteModule[], params: Record<string, string | string[]>): number {
|
|
779
|
+
const current = ctx?.matches() ?? [];
|
|
780
|
+
const prevParams = ctx?.params() ?? {};
|
|
781
|
+
const shared = Math.min(current.length, allModules.length);
|
|
782
|
+
for (let i = 0; i < shared; i++) {
|
|
783
|
+
const prev = current[i];
|
|
784
|
+
const mod = allModules[i];
|
|
785
|
+
if (
|
|
786
|
+
!prev ||
|
|
787
|
+
!mod ||
|
|
788
|
+
prev.virtualPath !== mod.virtualPath ||
|
|
789
|
+
prev._type !== mod._type ||
|
|
790
|
+
!ownParamsUnchanged(mod.virtualPath, prevParams, params)
|
|
791
|
+
) {
|
|
792
|
+
return i;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
if (current.length !== allModules.length) return shared;
|
|
796
|
+
/* Nothing replaced (search-only refresh): the deepest route updates in place. */
|
|
797
|
+
return Math.max(0, allModules.length - 1);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/** DOM nodes a commit of `allModules` swaps, read before the commit runs. */
|
|
801
|
+
function swappedNodes(allModules: LoadedRouteModule[], params: Record<string, string | string[]>): readonly Node[] {
|
|
802
|
+
return outletNodes(changedDepth(allModules, params));
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* Start a view transition around `update` when the config, the `types` hook and the browser allow
|
|
807
|
+
* one. `started: false` means no transition ran and `update` has not run. A started call without a
|
|
808
|
+
* transition object means the API ran `update` itself. The transition runs on the innermost
|
|
809
|
+
* <ViewTransitionBoundary> around the nodes `update` swaps when the browser supports element-scoped
|
|
810
|
+
* transitions; otherwise, or with `scope: "document"`, on the document.
|
|
811
|
+
*/
|
|
812
|
+
function startNavigationTransition(
|
|
813
|
+
resolvedVT: ViewTransitionConfig,
|
|
814
|
+
update: () => void,
|
|
815
|
+
options: InternalNavigateOptions,
|
|
816
|
+
url: URL,
|
|
817
|
+
swapped: () => readonly Node[],
|
|
818
|
+
): { started: boolean; transition?: ViewTransitionResult } {
|
|
819
|
+
const doc = typeof document !== "undefined" ? document : null;
|
|
820
|
+
if (!doc || !hasViewTransitions(doc) || !resolvedVT) return { started: false };
|
|
821
|
+
const info = () => locationChange(options, url);
|
|
822
|
+
let types: string[] = [];
|
|
823
|
+
if (typeof resolvedVT === "object" && resolvedVT.types) {
|
|
824
|
+
const rawTypes = resolvedVT.types;
|
|
825
|
+
if (typeof rawTypes === "function") {
|
|
826
|
+
const result = rawTypes(info());
|
|
827
|
+
if (result === false) return { started: false };
|
|
828
|
+
types = result;
|
|
829
|
+
} else {
|
|
830
|
+
types = rawTypes;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/* An intercept or not-found flip is not confined to one outlet: use the document. */
|
|
835
|
+
const confined = ctx ? ctx.intercepted() === null && !ctx.notFound() : true;
|
|
836
|
+
const scopeEl =
|
|
837
|
+
confined && hasElementViewTransitions() && resolveScope(options, info) === "auto"
|
|
838
|
+
? resolveTransitionScope(registeredBoundaries(), swapped())
|
|
839
|
+
: null;
|
|
840
|
+
if (import.meta.env.DEV) {
|
|
841
|
+
warnMisplacedBoundaries();
|
|
842
|
+
verbose("nav", `view transition scope: ${scopeEl ? `<${scopeEl.tagName.toLowerCase()}>` : "document"}`);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
const run = (target: ViewTransitionDocument) =>
|
|
846
|
+
types.length > 0 ? target.startViewTransition({ types, update }) : target.startViewTransition(update);
|
|
847
|
+
|
|
848
|
+
if (activeTransition) {
|
|
849
|
+
try {
|
|
850
|
+
activeTransition.skipTransition();
|
|
851
|
+
} catch {
|
|
852
|
+
/* already finished */
|
|
853
|
+
}
|
|
854
|
+
activeTransition = null;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
let transition: ViewTransitionResult | undefined;
|
|
858
|
+
if (scopeEl) {
|
|
859
|
+
try {
|
|
860
|
+
transition = run(scopeEl as unknown as ViewTransitionDocument);
|
|
861
|
+
} catch (e: unknown) {
|
|
862
|
+
if (!warnedScopedStart) {
|
|
863
|
+
warnedScopedStart = true;
|
|
864
|
+
warn("nav", "element-scoped view transition failed; using a document transition", e);
|
|
865
|
+
}
|
|
866
|
+
transition = run(doc);
|
|
867
|
+
}
|
|
868
|
+
} else {
|
|
869
|
+
transition = run(doc);
|
|
870
|
+
}
|
|
871
|
+
if (transition) {
|
|
872
|
+
activeTransition = transition;
|
|
873
|
+
/* Dev: mark the scope while it animates, for devtools and tests. */
|
|
874
|
+
const marked = import.meta.env.DEV && scopeEl && transition !== undefined ? scopeEl : null;
|
|
875
|
+
marked?.setAttribute("data-flare-vt-scope", "");
|
|
876
|
+
const settled = () => {
|
|
877
|
+
if (activeTransition === transition) activeTransition = null;
|
|
878
|
+
marked?.removeAttribute("data-flare-vt-scope");
|
|
879
|
+
};
|
|
880
|
+
transition.finished.then(settled, settled);
|
|
881
|
+
}
|
|
882
|
+
return { started: true, transition };
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/** Enter "transitioning" for a transition whose route swap has landed; "idle" once it finishes.
|
|
886
|
+
* The version check keeps a superseded navigation's transition from resetting the phase. */
|
|
887
|
+
function trackTransition(transition: ViewTransitionResult, version: number): void {
|
|
888
|
+
if (!ctx) return;
|
|
889
|
+
ctx.setNavigationPhase("transitioning");
|
|
890
|
+
ctx.setViewTransition(transition);
|
|
891
|
+
transition.finished.then(
|
|
892
|
+
() => {
|
|
893
|
+
if (ctx && version === navigationVersion) {
|
|
894
|
+
ctx.setNavigationPhase("idle");
|
|
895
|
+
ctx.setViewTransition(null);
|
|
896
|
+
}
|
|
897
|
+
},
|
|
898
|
+
(e: unknown) => {
|
|
899
|
+
warn("nav", "view transition finished with error", e);
|
|
900
|
+
if (ctx && version === navigationVersion) {
|
|
901
|
+
ctx.setNavigationPhase("idle");
|
|
902
|
+
ctx.setViewTransition(null);
|
|
903
|
+
}
|
|
904
|
+
},
|
|
905
|
+
);
|
|
906
|
+
}
|
|
907
|
+
|
|
716
908
|
/** Apply view transition with VT API, or call update() directly as fallback.
|
|
717
909
|
* Returns a promise that resolves after update() has executed so navigate()
|
|
718
910
|
* callers can rely on state being settled when the promise resolves.
|
|
@@ -726,100 +918,38 @@ async function applyViewTransition(
|
|
|
726
918
|
options: InternalNavigateOptions,
|
|
727
919
|
url: URL,
|
|
728
920
|
version: number,
|
|
921
|
+
swapped: () => readonly Node[],
|
|
729
922
|
): Promise<void> {
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
options._popstateDirection ?? (options._popstate ? "back" : "forward");
|
|
741
|
-
const fromLoc = ctx
|
|
742
|
-
? {
|
|
743
|
-
hash: ctx.location().hash,
|
|
744
|
-
pathname: ctx.location().pathname,
|
|
745
|
-
search: serializeSearchParams(ctx.location().search),
|
|
746
|
-
}
|
|
747
|
-
: null;
|
|
748
|
-
const toLoc = { hash: url.hash, pathname: url.pathname, search: url.search };
|
|
749
|
-
const info: LocationChangeInfo = {
|
|
750
|
-
direction,
|
|
751
|
-
fromLocation: fromLoc,
|
|
752
|
-
pathChanged: fromLoc?.pathname !== toLoc.pathname,
|
|
753
|
-
toLocation: toLoc,
|
|
754
|
-
};
|
|
755
|
-
const result = rawTypes(info);
|
|
756
|
-
if (result === false) {
|
|
757
|
-
update();
|
|
758
|
-
stopNavigation();
|
|
759
|
-
return;
|
|
760
|
-
}
|
|
761
|
-
if (result.length > 0) {
|
|
762
|
-
transition = startVT({ types: result, update });
|
|
763
|
-
} else {
|
|
764
|
-
transition = startVT(update);
|
|
765
|
-
}
|
|
766
|
-
} else if (rawTypes.length > 0) {
|
|
767
|
-
transition = startVT({ types: rawTypes, update });
|
|
768
|
-
} else {
|
|
769
|
-
transition = startVT(update);
|
|
770
|
-
}
|
|
771
|
-
} else {
|
|
772
|
-
transition = startVT(update);
|
|
773
|
-
}
|
|
774
|
-
} catch (e: unknown) {
|
|
775
|
-
warn("nav", "view transition API failed", e);
|
|
776
|
-
update();
|
|
777
|
-
stopNavigation();
|
|
778
|
-
return;
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
if (transition) {
|
|
782
|
-
/**
|
|
783
|
-
* WebKit rejects transition.ready with AbortError when a new startViewTransition
|
|
784
|
-
* call replaces an in-flight one. Chromium swallows this internally but WebKit
|
|
785
|
-
* surfaces it as an unhandledrejection, which triggers the dev error overlay and
|
|
786
|
-
* blocks pointer events. The finished promise is already handled below.
|
|
787
|
-
*/
|
|
788
|
-
transition.ready.catch(() => {});
|
|
789
|
-
|
|
790
|
-
await transition.updateCallbackDone;
|
|
791
|
-
|
|
792
|
-
/* State is settled — enter transitioning phase while VT animation plays */
|
|
793
|
-
if (ctx) {
|
|
794
|
-
ctx.setNavigationPhase("transitioning");
|
|
795
|
-
ctx.setViewTransition(transition);
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
/* Wire finished → idle (catch rejection too — VT can be skipped/aborted).
|
|
799
|
-
* Version check prevents stale VT from resetting phase when a new navigation superseded this one. */
|
|
800
|
-
transition.finished.then(
|
|
801
|
-
() => {
|
|
802
|
-
if (ctx && version === navigationVersion) {
|
|
803
|
-
ctx.setNavigationPhase("idle");
|
|
804
|
-
ctx.setViewTransition(null);
|
|
805
|
-
}
|
|
806
|
-
},
|
|
807
|
-
(e: unknown) => {
|
|
808
|
-
warn("nav", "view transition finished with error", e);
|
|
809
|
-
if (ctx && version === navigationVersion) {
|
|
810
|
-
ctx.setNavigationPhase("idle");
|
|
811
|
-
ctx.setViewTransition(null);
|
|
812
|
-
}
|
|
813
|
-
},
|
|
814
|
-
);
|
|
815
|
-
} else {
|
|
816
|
-
/* startVT returned void/undefined — update ran inside it, just clean up */
|
|
817
|
-
stopNavigation();
|
|
818
|
-
}
|
|
819
|
-
} else {
|
|
923
|
+
let started: ReturnType<typeof startNavigationTransition>;
|
|
924
|
+
try {
|
|
925
|
+
started = startNavigationTransition(resolvedVT, update, options, url, swapped);
|
|
926
|
+
} catch (e: unknown) {
|
|
927
|
+
warn("nav", "view transition API failed", e);
|
|
928
|
+
update();
|
|
929
|
+
stopNavigation();
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
if (!started.started) {
|
|
820
933
|
update();
|
|
821
934
|
stopNavigation();
|
|
935
|
+
return;
|
|
822
936
|
}
|
|
937
|
+
const transition = started.transition;
|
|
938
|
+
if (!transition) {
|
|
939
|
+
/* startVT returned void/undefined — update ran inside it, just clean up */
|
|
940
|
+
stopNavigation();
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
/**
|
|
944
|
+
* WebKit rejects transition.ready with AbortError when a new startViewTransition
|
|
945
|
+
* call replaces an in-flight one. Chromium swallows this internally but WebKit
|
|
946
|
+
* surfaces it as an unhandledrejection, which triggers the dev error overlay and
|
|
947
|
+
* blocks pointer events. The finished promise is handled in trackTransition.
|
|
948
|
+
*/
|
|
949
|
+
transition.ready.catch(() => {});
|
|
950
|
+
await transition.updateCallbackDone;
|
|
951
|
+
/* State is settled — enter transitioning phase while VT animation plays */
|
|
952
|
+
trackTransition(transition, version);
|
|
823
953
|
}
|
|
824
954
|
|
|
825
955
|
export async function navigate(options: InternalNavigateOptions, redirectCount = 0): Promise<void> {
|
|
@@ -1129,6 +1259,9 @@ export async function navigate(options: InternalNavigateOptions, redirectCount =
|
|
|
1129
1259
|
}
|
|
1130
1260
|
|
|
1131
1261
|
let paintedShell = false;
|
|
1262
|
+
/* The view transition started around the cached shell, if one was painted. */
|
|
1263
|
+
let shellTransition: ViewTransitionResult | undefined;
|
|
1264
|
+
let shellCommitted: Promise<void> | undefined;
|
|
1132
1265
|
let paintedModules: LoadedRouteModule[] | undefined;
|
|
1133
1266
|
let paintedSearch: SearchParams | undefined;
|
|
1134
1267
|
let paintedParams: Record<string, string | string[]> | undefined;
|
|
@@ -1166,6 +1299,8 @@ export async function navigate(options: InternalNavigateOptions, redirectCount =
|
|
|
1166
1299
|
const rootLayout = modules.layouts.find((m) => m._type === "root-layout");
|
|
1167
1300
|
const headModules = rootLayout ? [rootLayout, ...allModules] : allModules;
|
|
1168
1301
|
|
|
1302
|
+
const resolvedVT = options.viewTransition ?? defaultViewTransition;
|
|
1303
|
+
|
|
1169
1304
|
/* Instant navigation: reuse in-flight prefetch and paint a cached shell
|
|
1170
1305
|
* before the enter NDJSON hop. Skipped when this nav already fetched in
|
|
1171
1306
|
* parallel (first visit, no cache). */
|
|
@@ -1184,29 +1319,52 @@ export async function navigate(options: InternalNavigateOptions, redirectCount =
|
|
|
1184
1319
|
}
|
|
1185
1320
|
}
|
|
1186
1321
|
if (controller.signal.aborted || myVersion !== navigationVersion) return;
|
|
1187
|
-
const shell =
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1322
|
+
const shell = prepareCachedShell(c, allModules, rootLayout, search, modules.params);
|
|
1323
|
+
if (shell) {
|
|
1324
|
+
hadShell = true;
|
|
1325
|
+
paintedShell = true;
|
|
1191
1326
|
paintedModules = allModules;
|
|
1192
1327
|
paintedSearch = search;
|
|
1193
1328
|
paintedParams = modules.params;
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
if (
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1329
|
+
keepMatchIds = shell.keepMatchIds;
|
|
1330
|
+
const paint = () => {
|
|
1331
|
+
if (myVersion !== navigationVersion) return;
|
|
1332
|
+
shell.commit();
|
|
1333
|
+
/* Restore before the next paint so back/forward does not flash at y=0
|
|
1334
|
+
* while the enter hop (or the post-update rAF) is still outstanding. */
|
|
1335
|
+
if (!scrollRestorationEnabled) return;
|
|
1336
|
+
flush();
|
|
1337
|
+
if (options._restoreScroll) {
|
|
1338
|
+
restoreScroll(options._restoreScroll, "auto");
|
|
1339
|
+
} else if (options.scroll !== false) {
|
|
1340
|
+
if (url.hash) {
|
|
1341
|
+
const el = typeof document !== "undefined" ? document.getElementById(url.hash.slice(1)) : null;
|
|
1342
|
+
if (el) el.scrollIntoView();
|
|
1343
|
+
else scrollToTop();
|
|
1344
|
+
} else {
|
|
1345
|
+
scrollToTop();
|
|
1346
|
+
}
|
|
1209
1347
|
}
|
|
1348
|
+
};
|
|
1349
|
+
/* The shell is the first route swap, so the navigation's one view transition wraps it;
|
|
1350
|
+
* the post-fetch update below then runs outside any transition. */
|
|
1351
|
+
let started: ReturnType<typeof startNavigationTransition> = { started: false };
|
|
1352
|
+
try {
|
|
1353
|
+
started = startNavigationTransition(resolvedVT, paint, options, url, () =>
|
|
1354
|
+
swappedNodes(allModules, modules.params),
|
|
1355
|
+
);
|
|
1356
|
+
} catch (e: unknown) {
|
|
1357
|
+
warn("nav", "view transition API failed", e);
|
|
1358
|
+
}
|
|
1359
|
+
if (started.transition) {
|
|
1360
|
+
shellTransition = started.transition;
|
|
1361
|
+
shellTransition.ready.catch(() => {});
|
|
1362
|
+
shellTransition.finished.catch(() => {});
|
|
1363
|
+
shellCommitted = shellTransition.updateCallbackDone.catch(() => {});
|
|
1364
|
+
/* Phase stays "loading" until the data lands; the transition is visible meanwhile. */
|
|
1365
|
+
ctx.setViewTransition(shellTransition);
|
|
1366
|
+
} else if (!started.started) {
|
|
1367
|
+
paint();
|
|
1210
1368
|
}
|
|
1211
1369
|
}
|
|
1212
1370
|
}
|
|
@@ -1488,11 +1646,25 @@ export async function navigate(options: InternalNavigateOptions, redirectCount =
|
|
|
1488
1646
|
/* Step 13: Apply view transition or direct update.
|
|
1489
1647
|
* Await ensures navigate() doesn't resolve until update() has run,
|
|
1490
1648
|
* so callers see settled state (matches, params, head) after await.
|
|
1491
|
-
*
|
|
1492
|
-
* -
|
|
1493
|
-
*
|
|
1494
|
-
|
|
1495
|
-
|
|
1649
|
+
* One transition per navigation, around the first route swap:
|
|
1650
|
+
* - Shell painted inside a transition: wait for that paint, then update outside it;
|
|
1651
|
+
* "transitioning" while it still animates, "idle" when it finishes.
|
|
1652
|
+
* - Otherwise applyViewTransition wraps this update ("transitioning" after
|
|
1653
|
+
* updateCallbackDone, "idle" on finished), or runs it directly. */
|
|
1654
|
+
if (shellTransition) {
|
|
1655
|
+
await shellCommitted;
|
|
1656
|
+
if (myVersion !== navigationVersion) return;
|
|
1657
|
+
update();
|
|
1658
|
+
trackTransition(shellTransition, myVersion);
|
|
1659
|
+
} else if (hadShell) {
|
|
1660
|
+
/* The shell already swapped the route without a transition; don't start one now. */
|
|
1661
|
+
update();
|
|
1662
|
+
stopNavigation();
|
|
1663
|
+
} else {
|
|
1664
|
+
await applyViewTransition(resolvedVT, update, options, url, myVersion, () =>
|
|
1665
|
+
swappedNodes(allModules, modules.params),
|
|
1666
|
+
);
|
|
1667
|
+
}
|
|
1496
1668
|
} catch (error: unknown) {
|
|
1497
1669
|
if (error instanceof RedirectResponse) {
|
|
1498
1670
|
if (error.external) {
|
|
@@ -1667,6 +1839,10 @@ export function hardNavigate(href: string): void {
|
|
|
1667
1839
|
|
|
1668
1840
|
export function resetNavigationState(): void {
|
|
1669
1841
|
ctx = null;
|
|
1842
|
+
activeTransition = null;
|
|
1843
|
+
warnedScopedStart = false;
|
|
1844
|
+
resetOutletNodes();
|
|
1845
|
+
resetViewTransitionBoundaries();
|
|
1670
1846
|
currentController = null;
|
|
1671
1847
|
navigationVersion = 0;
|
|
1672
1848
|
scrollStore = null;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { warn } from "../logger.ts";
|
|
2
|
+
|
|
3
|
+
const warned = new WeakSet<Element>();
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* Element-scoped view transitions need a box: the browser rejects `display: contents`, inline and
|
|
7
|
+
* SVG scopes (`ready` rejects with InvalidStateError) and silently skips hidden or detached ones.
|
|
8
|
+
*/
|
|
9
|
+
function canScope(element: Element): boolean {
|
|
10
|
+
if (!element.isConnected) return false;
|
|
11
|
+
if (element === element.ownerDocument.documentElement) return false;
|
|
12
|
+
if (typeof SVGElement !== "undefined" && element instanceof SVGElement) return false;
|
|
13
|
+
const display = getComputedStyle(element).display;
|
|
14
|
+
if (display === "contents" || display === "none" || display.startsWith("inline")) {
|
|
15
|
+
if (!warned.has(element)) {
|
|
16
|
+
warned.add(element);
|
|
17
|
+
warn("nav", `<ViewTransitionBoundary> child has display: ${display}; using a document transition`);
|
|
18
|
+
}
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The element to scope a navigation's view transition to: the innermost boundary that contains
|
|
26
|
+
* every node the update swaps and is not inside any of them (the update would remove it). Null
|
|
27
|
+
* means a document transition.
|
|
28
|
+
*/
|
|
29
|
+
export function resolveTransitionScope(boundaries: Iterable<Element>, swapped: readonly Node[]): Element | null {
|
|
30
|
+
if (swapped.length === 0) return null;
|
|
31
|
+
const survivors: Element[] = [];
|
|
32
|
+
for (const boundary of new Set(boundaries)) {
|
|
33
|
+
if (!canScope(boundary)) continue;
|
|
34
|
+
if (!swapped.every((node) => boundary !== node && boundary.contains(node))) continue;
|
|
35
|
+
if (swapped.some((node) => node.contains(boundary))) continue;
|
|
36
|
+
survivors.push(boundary);
|
|
37
|
+
}
|
|
38
|
+
return (
|
|
39
|
+
survivors.find((candidate) => !survivors.some((other) => other !== candidate && candidate.contains(other))) ?? null
|
|
40
|
+
);
|
|
41
|
+
}
|
package/src/outlet/index.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type Accessor,
|
|
3
|
+
children,
|
|
3
4
|
createContext,
|
|
4
5
|
createEffect,
|
|
5
6
|
createMemo,
|
|
@@ -20,6 +21,7 @@ import { NotFoundError, UnauthenticatedError, UnauthorizedError } from "../error
|
|
|
20
21
|
import { createTranslator } from "../i18n/index.ts";
|
|
21
22
|
import { clearPendingNavigation, proceedPendingNavigation, setActiveBlocker } from "../navigation/index.ts";
|
|
22
23
|
import { matchRoute, toLocaleMatch } from "../router-primitives/tree.ts";
|
|
24
|
+
import { setOutletNodes } from "./outlet-nodes.ts";
|
|
23
25
|
import { buildUrl, parseSearchParams, type SearchParams } from "../url/index.ts";
|
|
24
26
|
import type {
|
|
25
27
|
BrowserViewTransition,
|
|
@@ -55,6 +57,7 @@ export type {
|
|
|
55
57
|
ViewTransitionConfig,
|
|
56
58
|
ViewTransitionDirection,
|
|
57
59
|
ViewTransitionOptions,
|
|
60
|
+
ViewTransitionScope,
|
|
58
61
|
} from "./types.ts";
|
|
59
62
|
|
|
60
63
|
/** Default `null` (not `undefined`) — Solid 2 throws ContextNotFoundError when default is undefined. */
|
|
@@ -487,7 +490,7 @@ function OutletContent(props: { depth: number; fallback?: JSX.Element }): JSX.El
|
|
|
487
490
|
* values. Instant shells reuse the same match object so local page signals
|
|
488
491
|
* survive; a new object (different route or error identity) must remount.
|
|
489
492
|
*/
|
|
490
|
-
|
|
493
|
+
const content = children(() => (
|
|
491
494
|
<Show
|
|
492
495
|
fallback={ctx.notFound() && props.depth === 0 ? resolveNotFoundBoundary(ctx, props.depth) : null}
|
|
493
496
|
keyed
|
|
@@ -495,7 +498,14 @@ function OutletContent(props: { depth: number; fallback?: JSX.Element }): JSX.El
|
|
|
495
498
|
>
|
|
496
499
|
{(m) => <MatchOutlet depth={props.depth} fallback={props.fallback} match={m} />}
|
|
497
500
|
</Show>
|
|
498
|
-
)
|
|
501
|
+
));
|
|
502
|
+
/* Record what this depth renders so a navigation can scope its view transition to the
|
|
503
|
+
* <ViewTransitionBoundary> around it. */
|
|
504
|
+
createEffect(
|
|
505
|
+
() => content.toArray().filter((n): n is Node => typeof Node !== "undefined" && n instanceof Node),
|
|
506
|
+
(nodes) => setOutletNodes(props.depth, nodes),
|
|
507
|
+
);
|
|
508
|
+
return content as unknown as JSX.Element;
|
|
499
509
|
}
|
|
500
510
|
|
|
501
511
|
function MatchOutlet(props: { depth: number; fallback?: JSX.Element; match: ClientMatch }): JSX.Element {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The DOM nodes each outlet depth currently renders. Navigation reads the depth an update swaps
|
|
3
|
+
* to scope its view transition to the boundary around them. Internal.
|
|
4
|
+
*/
|
|
5
|
+
const nodesByDepth = new Map<number, readonly Node[]>();
|
|
6
|
+
|
|
7
|
+
export function setOutletNodes(depth: number, nodes: readonly Node[]): () => void {
|
|
8
|
+
nodesByDepth.set(depth, nodes);
|
|
9
|
+
return () => {
|
|
10
|
+
if (nodesByDepth.get(depth) === nodes) nodesByDepth.delete(depth);
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function outletNodes(depth: number): readonly Node[] {
|
|
15
|
+
return nodesByDepth.get(depth) ?? [];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Every node any outlet depth renders. */
|
|
19
|
+
export function allOutletNodes(): Node[] {
|
|
20
|
+
return [...nodesByDepth.values()].flat();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Tests only. */
|
|
24
|
+
export function resetOutletNodes(): void {
|
|
25
|
+
nodesByDepth.clear();
|
|
26
|
+
}
|
package/src/outlet/types.ts
CHANGED
|
@@ -27,8 +27,15 @@ export interface LocationChangeInfo {
|
|
|
27
27
|
toLocation: { hash: string; pathname: string; search: string };
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Where a navigation's view transition runs. "auto": the innermost <ViewTransitionBoundary> around
|
|
32
|
+
* the swapped route content, else the document. "document": always the document.
|
|
33
|
+
*/
|
|
34
|
+
export type ViewTransitionScope = "auto" | "document";
|
|
35
|
+
|
|
30
36
|
export interface ViewTransitionOptions {
|
|
31
|
-
|
|
37
|
+
scope?: ViewTransitionScope | ((info: LocationChangeInfo) => ViewTransitionScope);
|
|
38
|
+
types?: string[] | ((info: LocationChangeInfo) => string[] | false);
|
|
32
39
|
}
|
|
33
40
|
|
|
34
41
|
export type ViewTransitionConfig = boolean | ViewTransitionOptions;
|
package/src/plugins/virtual.d.ts
CHANGED
|
@@ -25,11 +25,6 @@ declare module "virtual:flare-is-dev" {
|
|
|
25
25
|
export default isDev;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
declare module "virtual:flare-log-level" {
|
|
29
|
-
const level: "error" | "silent" | "verbose" | "warn";
|
|
30
|
-
export default level;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
28
|
declare module "virtual:flare-config" {
|
|
34
29
|
const config: Record<string, unknown>;
|
|
35
30
|
export default config;
|
package/src/plugins/virtual.ts
CHANGED
|
@@ -80,6 +80,7 @@ export function createVirtualPlugin(
|
|
|
80
80
|
return {
|
|
81
81
|
define: {
|
|
82
82
|
__FLARE_IS_DEV__: JSON.stringify(isDevMode),
|
|
83
|
+
__FLARE_LOG_LEVEL__: JSON.stringify(config.logLevel ?? (isDevMode ? "warn" : "error")),
|
|
83
84
|
},
|
|
84
85
|
};
|
|
85
86
|
},
|
|
@@ -129,11 +130,6 @@ export function createVirtualPlugin(
|
|
|
129
130
|
const dev = isDevMode || this.environment?.config?.mode === "development";
|
|
130
131
|
return { code: `export default ${dev}`, moduleType: "js" };
|
|
131
132
|
}
|
|
132
|
-
if (id === "\0virtual:flare-log-level") {
|
|
133
|
-
const dev = isDevMode || this.environment?.config?.mode === "development";
|
|
134
|
-
const level = config.logLevel ?? (dev ? "warn" : "error");
|
|
135
|
-
return { code: `export default "${level}"`, moduleType: "js" };
|
|
136
|
-
}
|
|
137
133
|
if (id === "\0virtual:flare-module-preloads") {
|
|
138
134
|
const mode = this.environment?.config?.mode ?? "production";
|
|
139
135
|
if (mode === "development") {
|
|
@@ -180,7 +176,6 @@ export function createVirtualPlugin(
|
|
|
180
176
|
if (id === "virtual:flare-client-entry") return "\0virtual:flare-client-entry";
|
|
181
177
|
if (id === "virtual:flare-generated") return "\0virtual:flare-generated";
|
|
182
178
|
if (id === "virtual:flare-is-dev") return "\0virtual:flare-is-dev";
|
|
183
|
-
if (id === "virtual:flare-log-level") return "\0virtual:flare-log-level";
|
|
184
179
|
if (id === "virtual:flare-module-preloads") return "\0virtual:flare-module-preloads";
|
|
185
180
|
if (id === "virtual:flare-sx-manifest") return "\0virtual:flare-sx-manifest";
|
|
186
181
|
return null;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ViewTransitionConfig } from "../outlet/types.ts";
|
|
1
2
|
import type { DirectionConfig } from "../direction.ts";
|
|
2
3
|
import type { LocaleConfig } from "../locale/index.tsx";
|
|
3
4
|
import type { LocationRewrite } from "../rewrite/index.ts";
|
|
@@ -8,7 +9,7 @@ import type { ThemeConfig } from "../theme.ts";
|
|
|
8
9
|
|
|
9
10
|
export type PrefetchStrategy = false | "intent" | "render" | "viewport";
|
|
10
11
|
export type TrailingSlashMode = "always" | "never" | "preserve";
|
|
11
|
-
export type ViewTransitionDefaults =
|
|
12
|
+
export type ViewTransitionDefaults = ViewTransitionConfig;
|
|
12
13
|
|
|
13
14
|
export interface RouterCacheConfig {
|
|
14
15
|
client?: ClientCacheConfig | false;
|
package/src/router.ts
CHANGED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { children, createEffect } from "solid-js";
|
|
2
|
+
import type { JSX } from "@solidjs/web";
|
|
3
|
+
import { registerBoundary } from "./registry.ts";
|
|
4
|
+
|
|
5
|
+
const MISUSE = "<ViewTransitionBoundary> needs exactly one element child (it renders no wrapper of its own); got";
|
|
6
|
+
|
|
7
|
+
let reportedMisuse = false;
|
|
8
|
+
|
|
9
|
+
function describeNodes(nodes: unknown[]): string {
|
|
10
|
+
return nodes
|
|
11
|
+
.map((n) => (n instanceof Element ? `<${n.tagName.toLowerCase()}>` : typeof n === "string" ? "text" : String(n)))
|
|
12
|
+
.join(", ");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Scope view transitions of navigations inside it to its child element: content outside (sidebar,
|
|
17
|
+
* header, tabs) keeps hover, clicks and CSS transitions while the child animates.
|
|
18
|
+
*
|
|
19
|
+
* ```tsx
|
|
20
|
+
* <aside>…</aside>
|
|
21
|
+
* <ViewTransitionBoundary>
|
|
22
|
+
* <main>{props.children}</main>
|
|
23
|
+
* </ViewTransitionBoundary>
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* Renders its child unchanged. The child must resolve to exactly one element; a component child
|
|
27
|
+
* works when it renders one root element, and an empty child (a false `<Show>`) is inactive.
|
|
28
|
+
*/
|
|
29
|
+
export function ViewTransitionBoundary(props: { children: JSX.Element }): JSX.Element {
|
|
30
|
+
const resolved = children(() => props.children);
|
|
31
|
+
|
|
32
|
+
createEffect(
|
|
33
|
+
() => resolved.toArray().filter((n) => n !== null && n !== undefined && n !== false && n !== ""),
|
|
34
|
+
(nodes) => {
|
|
35
|
+
if (nodes.length === 0) return;
|
|
36
|
+
const [node] = nodes;
|
|
37
|
+
if (nodes.length > 1 || !(node instanceof Element)) {
|
|
38
|
+
const message = `${MISUSE} ${describeNodes(nodes)}`;
|
|
39
|
+
if (import.meta.env.DEV) throw new Error(message);
|
|
40
|
+
if (!reportedMisuse) {
|
|
41
|
+
reportedMisuse = true;
|
|
42
|
+
console.error(message);
|
|
43
|
+
}
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
return registerBoundary(node);
|
|
47
|
+
},
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
return resolved as unknown as JSX.Element;
|
|
51
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Elements that may scope a navigation's view transition. Navigation picks the innermost one that
|
|
3
|
+
* wraps the swapped route content; none → a document transition. Internal: not a public export.
|
|
4
|
+
*/
|
|
5
|
+
const boundaries = new Set<Element>();
|
|
6
|
+
|
|
7
|
+
/** Registered boundary elements, in registration order. */
|
|
8
|
+
export function registeredBoundaries(): ReadonlySet<Element> {
|
|
9
|
+
return boundaries;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function registerBoundary(element: Element): () => void {
|
|
13
|
+
boundaries.add(element);
|
|
14
|
+
return () => {
|
|
15
|
+
boundaries.delete(element);
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Clear the registry. Tests only. */
|
|
20
|
+
export function resetViewTransitionBoundaries(): void {
|
|
21
|
+
boundaries.clear();
|
|
22
|
+
}
|