@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 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 | You get |
1133
- | --------------------------------------- | --------------------------------------- |
1134
- | `@lovrozagar/flare` | `createRouter`, hooks, `navigate` |
1135
- | `@lovrozagar/flare/router` | `createRouter` |
1136
- | `@lovrozagar/flare/page` | `createPage` |
1137
- | `@lovrozagar/flare/layout` | `createLayout` |
1138
- | `@lovrozagar/flare/root-layout` | `createRootLayout` |
1139
- | `@lovrozagar/flare/path-segment` | `createPathSegment` |
1140
- | `@lovrozagar/flare/link` | `Link` |
1141
- | `@lovrozagar/flare/outlet` | `Outlet` |
1142
- | `@lovrozagar/flare/hydrate` | `hydrate` |
1143
- | `@lovrozagar/flare/client` | `createClient` |
1144
- | `@lovrozagar/flare/await` | `<Await>` |
1145
- | `@lovrozagar/flare/form` | `Form`, `FieldError` |
1146
- | `@lovrozagar/flare/server-fn` | `createServerFn` |
1147
- | `@lovrozagar/flare/server-fn-query` | server fn ↔ Query |
1148
- | `@lovrozagar/flare/plugins` | `flare()` |
1149
- | `@lovrozagar/flare/styles` | `styles`, `cn`, `compileSx` |
1150
- | `@lovrozagar/flare/fonts` | `FontCSS`, `createFont` |
1151
- | `@lovrozagar/flare/fonts/<family>` | `import { inter } from "…/fonts/inter"` |
1152
- | `@lovrozagar/flare/image` | `Image`, `configureImage` |
1153
- | `@lovrozagar/flare/theme` | `ThemeScript`, `ThemeProvider` |
1154
- | `@lovrozagar/flare/direction` | `DirectionScript` |
1155
- | `@lovrozagar/flare/locale` | `LocaleScript`, `LocaleProvider` |
1156
- | `@lovrozagar/flare/i18n` | `createTranslations`, `formatMessage` |
1157
- | `@lovrozagar/flare/middleware` | `onPage`, `virtualPath`, types |
1158
- | `@lovrozagar/flare/middleware/*` | builtins |
1159
- | `@lovrozagar/flare/errors` | `NotFoundError`, redirects, auth errors |
1160
- | `@lovrozagar/flare/security` | `SecurityConfig` |
1161
- | `@lovrozagar/flare/revalidation` | `createRevalidateFn` |
1162
- | `@lovrozagar/flare/store` | `FlareStore` |
1163
- | `@lovrozagar/flare/store-filesystem` | disk store |
1164
- | `@lovrozagar/flare/query-client` | `createQueryClientGetter` |
1165
- | `@lovrozagar/flare/suspense-query` | `useSuspenseQuery` |
1166
- | `@lovrozagar/flare/broadcast` | cross-tab |
1167
- | `@lovrozagar/flare/lazy` | `lazy`, `clientLazy` |
1168
- | `@lovrozagar/flare/server` | `createServer` |
1169
- | `@lovrozagar/flare/server-context` | ALS, `background` |
1170
- | `@lovrozagar/flare/fetch-dedupe` | `withFetchDedupe` |
1171
- | `@lovrozagar/flare/server-only` | `createServerOnlyFn` |
1172
- | `@lovrozagar/flare/client-only` | `createClientOnlyFn` |
1173
- | `@lovrozagar/flare/isomorphic` | `createIsomorphicFn` |
1174
- | `@lovrozagar/flare/testing` | Playwright helpers |
1175
- | `@lovrozagar/flare/sitemap` | sitemap XML |
1176
- | `@lovrozagar/flare/search-engine` | IndexNow / Google / Bing |
1177
- | `@lovrozagar/flare/rewrite` | `LocationRewrite` |
1178
- | `@lovrozagar/flare/mount` | `mount` |
1179
- | `@lovrozagar/flare/intercept-outlet` | `InterceptOutlet` |
1180
- | `@lovrozagar/flare/navigation-progress` | `<NavigationProgress>` |
1181
- | `@lovrozagar/flare/reset-css` | `<ResetCSS>` |
1182
- | `@lovrozagar/flare/view-transition-css` | `<ViewTransitionCSS>` |
1183
- | `@lovrozagar/flare/prerender` | `loadPrerenderArtifacts` |
1184
- | `@lovrozagar/flare/tracing` | timing / OTel |
1185
- | `@lovrozagar/flare/validation` | `Validator`, `runValidator` |
1186
- | `@lovrozagar/flare/codegen` | generated types |
1187
- | `@lovrozagar/flare/generators` | `runGenerate` |
1188
- | `@lovrozagar/flare/virtual-types` | `/// <reference types="…" />` |
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.13",
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
  },
@@ -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 {
@@ -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?: boolean | { types: string[] };
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?: boolean | { types: string[] };
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 = defaultLevel;
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;
@@ -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
- * Paint cached/prefetched matches immediately so click is not blocked on NDJSON.
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 commitCachedShell(
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
- ): { hadShell: boolean; keepMatchIds: string[] } {
593
- if (!ctx) return { hadShell: false, keepMatchIds: [] };
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 { hadShell: false, keepMatchIds: [] };
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
- c.setIntercepted(null);
624
- c.setNotFound(false);
625
- assignMatches(c, buildClientMatches(allModules, search, params));
626
- c.setParams(params);
627
- c.setSearch(search);
628
- syncLocale(params);
629
- if (heads.length > 0) applyPerRouteHeads(heads);
630
- return { hadShell: true, keepMatchIds };
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
- const doc = typeof document !== "undefined" ? document : null;
731
- if (doc && hasViewTransitions(doc) && resolvedVT) {
732
- const startVT = doc.startViewTransition.bind(doc);
733
-
734
- let transition: ViewTransitionResult | undefined;
735
- try {
736
- if (typeof resolvedVT === "object" && resolvedVT.types) {
737
- const rawTypes = resolvedVT.types;
738
- if (typeof rawTypes === "function") {
739
- const direction: ViewTransitionDirection =
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 = commitCachedShell(c, allModules, rootLayout, search, modules.params);
1188
- hadShell = shell.hadShell;
1189
- paintedShell = shell.hadShell;
1190
- if (shell.hadShell) {
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
- keepMatchIds = shell.keepMatchIds;
1196
- /* Restore before the next paint so back/forward does not flash at y=0
1197
- * while the enter hop (or the post-update rAF) is still outstanding. */
1198
- if (hadShell && scrollRestorationEnabled) {
1199
- flush();
1200
- if (options._restoreScroll) {
1201
- restoreScroll(options._restoreScroll, "auto");
1202
- } else if (options.scroll !== false) {
1203
- if (url.hash) {
1204
- const el = typeof document !== "undefined" ? document.getElementById(url.hash.slice(1)) : null;
1205
- if (el) el.scrollIntoView();
1206
- else scrollToTop();
1207
- } else {
1208
- scrollToTop();
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
- * Phase management is handled by applyViewTransition:
1492
- * - VT path: "transitioning" after updateCallbackDone, "idle" on finished
1493
- * - No VT: "idle" immediately after update() */
1494
- const resolvedVT = options.viewTransition ?? defaultViewTransition;
1495
- await applyViewTransition(resolvedVT, update, options, url, myVersion);
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
+ }
@@ -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
- return (
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
- ) as JSX.Element;
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
+ }
@@ -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
- types: string[] | ((info: LocationChangeInfo) => string[] | false);
37
+ scope?: ViewTransitionScope | ((info: LocationChangeInfo) => ViewTransitionScope);
38
+ types?: string[] | ((info: LocationChangeInfo) => string[] | false);
32
39
  }
33
40
 
34
41
  export type ViewTransitionConfig = boolean | ViewTransitionOptions;
@@ -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;
@@ -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 = boolean | { types: string[] };
12
+ export type ViewTransitionDefaults = ViewTransitionConfig;
12
13
 
13
14
  export interface RouterCacheConfig {
14
15
  client?: ClientCacheConfig | false;
package/src/router.ts CHANGED
@@ -19,6 +19,7 @@ export type {
19
19
  ViewTransitionConfig,
20
20
  ViewTransitionDirection,
21
21
  ViewTransitionOptions,
22
+ ViewTransitionScope,
22
23
  } from "./outlet/index.tsx";
23
24
  export { useRouter } from "./outlet/index.tsx";
24
25
  export type {
@@ -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
+ }