Skip to content

Commit 2382dc4

Browse files
committed
Merge remote-tracking branch 'origin/main' into fry69/fix-basepath
# Conflicts: # packages/fresh/src/app.ts # packages/plugin-vite/tests/build_test.ts
2 parents 85f93e0 + 8b0d42b commit 2382dc4

20 files changed

Lines changed: 885 additions & 183 deletions

File tree

docs/latest/advanced/partials.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,13 @@ export default function LogView() {
201201
> children from existing ones, leading to subtle rendering bugs. Fresh will log
202202
> a warning if it detects a missing key on an append/prepend partial.
203203
204+
## View Transitions
205+
206+
Partial updates can be animated using the browser's
207+
[View Transitions API](/docs/advanced/view-transitions). Add `f-view-transition`
208+
alongside `f-client-nav` to enable smooth animated transitions between pages
209+
with zero JavaScript animation code.
210+
204211
## Bypassing or disabling Partials
205212

206213
If you want to exempt a particular element from triggering a partial request
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
---
2+
description: Animate page navigations with the View Transitions API
3+
---
4+
5+
Fresh integrates the browser's native
6+
[View Transitions API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API)
7+
into its [partials](/docs/advanced/partials) system. When enabled, DOM updates
8+
during client-side navigation are wrapped in `document.startViewTransition()`,
9+
giving you smooth animated transitions between pages with zero JavaScript
10+
animation code.
11+
12+
This is progressive enhancement -- if the browser doesn't support the View
13+
Transitions API, partials work exactly as before with no animation.
14+
15+
## Enabling view transitions
16+
17+
Add the `f-view-transition` attribute alongside `f-client-nav`:
18+
19+
```tsx routes/_app.tsx
20+
export default function App({ Component }: PageProps) {
21+
return (
22+
<html lang="en">
23+
<head>
24+
<meta charset="utf-8" />
25+
</head>
26+
<body f-client-nav f-view-transition>
27+
<Component />
28+
</body>
29+
</html>
30+
);
31+
}
32+
```
33+
34+
All partial navigations (link clicks, form submissions, back/forward) will now
35+
be animated.
36+
37+
## Customizing animations
38+
39+
The default view transition is a cross-fade. Customize it with standard CSS
40+
using the `::view-transition-old` and `::view-transition-new` pseudo-elements:
41+
42+
```css static/styles.css
43+
::view-transition-old(root) {
44+
animation: fade-out 0.2s ease-in;
45+
}
46+
::view-transition-new(root) {
47+
animation: fade-in 0.2s ease-out;
48+
}
49+
```
50+
51+
### Per-element transitions
52+
53+
Assign a `view-transition-name` in CSS to animate specific elements
54+
independently from the rest of the page:
55+
56+
```css static/styles.css
57+
.sidebar {
58+
view-transition-name: sidebar;
59+
}
60+
.main-content {
61+
view-transition-name: content;
62+
}
63+
```
64+
65+
Then target those named transitions:
66+
67+
```css static/styles.css
68+
/* Sidebar stays in place */
69+
::view-transition-old(sidebar),
70+
::view-transition-new(sidebar) {
71+
animation: none;
72+
}
73+
74+
/* Content slides */
75+
::view-transition-old(content) {
76+
animation: slide-out-left 0.3s ease-in;
77+
}
78+
::view-transition-new(content) {
79+
animation: slide-in-right 0.3s ease-out;
80+
}
81+
```
82+
83+
This is useful for keeping persistent UI (navigation bars, sidebars) stable
84+
while animating the main content area.
85+
86+
### Direction-aware animations
87+
88+
Since Fresh tracks navigation history, you can use CSS custom properties or
89+
classes to apply different animations for forward vs. backward navigation. The
90+
View Transitions API captures the old and new states automatically -- combine
91+
this with `::view-transition-group` to create directional slide effects.
92+
93+
## Disabling view transitions
94+
95+
Disable view transitions on a subtree by setting `f-view-transition={false}`:
96+
97+
```tsx
98+
<body f-client-nav f-view-transition={false}>
99+
```
100+
101+
## Browser support
102+
103+
View Transitions are supported in Chrome 111+, Edge 111+, and Safari 18+.
104+
Firefox support is in development. On unsupported browsers, navigations work
105+
normally without animation -- no polyfill needed.

docs/toc.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ const toc: RawTableOfContents = {
5454
["layouts", "Layouts", "link:latest"],
5555
["error-handling", "Error handling", "link:latest"],
5656
["partials", "Partials", "link:latest"],
57+
["view-transitions", "View Transitions", "link:latest"],
5758
["forms", "Forms", "link:latest"],
5859
["define", "Define Helpers", "link:latest"],
5960
["serialization", "Serialization", "link:latest"],

packages/fresh/src/app.ts

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,7 @@ import { trace } from "@opentelemetry/api";
33
import { DENO_DEPLOYMENT_ID } from "@fresh/build-id";
44
import * as colors from "@std/fmt/colors";
55
import { setBasePath } from "./runtime/shared.ts";
6-
import {
7-
type MaybeLazyMiddleware,
8-
type Middleware,
9-
runMiddlewares,
10-
} from "./middlewares/mod.ts";
6+
import type { MaybeLazyMiddleware, Middleware } from "./middlewares/mod.ts";
117
import { Context } from "./context.ts";
128
import { mergePath, type Method, UrlPatternRouter } from "./router.ts";
139
import type { FreshConfig, ResolvedFreshConfig } from "./config.ts";
@@ -432,12 +428,13 @@ export class App<State> {
432428

433429
setBasePath(this.config.basePath);
434430

435-
const router = new UrlPatternRouter<MaybeLazyMiddleware<State>>();
431+
const router = new UrlPatternRouter<Middleware<State>>();
436432

437-
const { rootMiddlewares } = applyCommands(
433+
const { rootHandler } = applyCommands(
438434
router,
439435
this.#commands,
440436
this.config.basePath,
437+
this.#onError,
441438
);
442439

443440
return async (
@@ -450,7 +447,7 @@ export class App<State> {
450447

451448
const method = req.method.toUpperCase() as Method;
452449
const matched = router.match(method, url);
453-
let { params, pattern, handlers, methodMatch } = matched;
450+
let { params, pattern, item: handler, methodMatch } = matched;
454451

455452
const span = trace.getActiveSpan();
456453
if (span && pattern) {
@@ -461,7 +458,7 @@ export class App<State> {
461458
let next: () => Promise<Response>;
462459

463460
if (pattern === null || !methodMatch) {
464-
handlers = rootMiddlewares;
461+
handler = rootHandler;
465462
}
466463

467464
if (matched.pattern !== null && !methodMatch) {
@@ -487,9 +484,7 @@ export class App<State> {
487484
);
488485

489486
try {
490-
if (handlers.length === 0) return await next();
491-
492-
const result = await runMiddlewares(handlers, ctx, this.#onError);
487+
const result = await (handler !== null ? handler(ctx) : next());
493488
if (!(result instanceof Response)) {
494489
throw new Error(
495490
`Expected a "Response" instance to be returned, but got: ${result}`,

packages/fresh/src/commands.ts

Lines changed: 41 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { setAdditionalStyles } from "./context.ts";
22
import { HttpError } from "./error.ts";
33
import { isHandlerByMethod, type PageResponse } from "./handlers.ts";
4-
import type { MaybeLazyMiddleware, Middleware } from "./middlewares/mod.ts";
4+
import {
5+
compileMiddlewares,
6+
type MaybeLazyMiddleware,
7+
type Middleware,
8+
} from "./middlewares/mod.ts";
59
import { mergePath, type Method, type Router, toRoutePath } from "./router.ts";
610
import {
711
getOrCreateSegment,
@@ -202,22 +206,25 @@ export type Command<State> =
202206
| FsRouteCommand<State>;
203207

204208
export function applyCommands<State>(
205-
router: Router<MaybeLazyMiddleware<State>>,
209+
router: Router<Middleware<State>>,
206210
commands: Command<State>[],
207211
basePath: string,
208-
): { rootMiddlewares: MaybeLazyMiddleware<State>[] } {
212+
onError?: (err: unknown) => void,
213+
): { rootHandler: Middleware<State> } {
209214
const root = newSegment<State>("", null);
210215

211-
applyCommandsInner(root, router, commands, basePath);
216+
applyCommandsInner(root, router, commands, basePath, onError);
212217

213-
return { rootMiddlewares: segmentToMiddlewares(root) };
218+
const rootMiddlewares = segmentToMiddlewares(root);
219+
return { rootHandler: compileMiddlewares(rootMiddlewares, onError) };
214220
}
215221

216222
function applyCommandsInner<State>(
217223
root: Segment<State>,
218-
router: Router<MaybeLazyMiddleware<State>>,
224+
router: Router<Middleware<State>>,
219225
commands: Command<State>[],
220226
basePath: string,
227+
onError?: (err: unknown) => void,
221228
) {
222229
for (let i = 0; i < commands.length; i++) {
223230
const cmd = commands[i];
@@ -290,18 +297,19 @@ function applyCommandsInner<State>(
290297
return renderRoute(ctx, def);
291298
});
292299

300+
const compiled = compileMiddlewares(fns, onError);
293301
if (config === undefined || config.methods === "ALL") {
294-
router.add("GET", routePath, fns);
295-
router.add("DELETE", routePath, fns);
296-
router.add("HEAD", routePath, fns);
297-
router.add("OPTIONS", routePath, fns);
298-
router.add("PATCH", routePath, fns);
299-
router.add("POST", routePath, fns);
300-
router.add("PUT", routePath, fns);
302+
router.add("GET", routePath, compiled);
303+
router.add("DELETE", routePath, compiled);
304+
router.add("HEAD", routePath, compiled);
305+
router.add("OPTIONS", routePath, compiled);
306+
router.add("PATCH", routePath, compiled);
307+
router.add("POST", routePath, compiled);
308+
router.add("PUT", routePath, compiled);
301309
} else if (Array.isArray(config.methods)) {
302310
for (let i = 0; i < config.methods.length; i++) {
303311
const method = config.methods[i];
304-
router.add(method, routePath, fns);
312+
router.add(method, routePath, compiled);
305313
}
306314
}
307315
} else {
@@ -313,17 +321,18 @@ function applyCommandsInner<State>(
313321
false,
314322
));
315323

324+
const compiled = compileMiddlewares(fns, onError);
316325
if (typeof route.handler === "function") {
317-
router.add("GET", routePath, fns);
318-
router.add("DELETE", routePath, fns);
319-
router.add("HEAD", routePath, fns);
320-
router.add("OPTIONS", routePath, fns);
321-
router.add("PATCH", routePath, fns);
322-
router.add("POST", routePath, fns);
323-
router.add("PUT", routePath, fns);
326+
router.add("GET", routePath, compiled);
327+
router.add("DELETE", routePath, compiled);
328+
router.add("HEAD", routePath, compiled);
329+
router.add("OPTIONS", routePath, compiled);
330+
router.add("PATCH", routePath, compiled);
331+
router.add("POST", routePath, compiled);
332+
router.add("PUT", routePath, compiled);
324333
} else if (isHandlerByMethod(route.handler!)) {
325334
for (const method of Object.keys(route.handler)) {
326-
router.add(method as Method, routePath, fns);
335+
router.add(method as Method, routePath, compiled);
327336
}
328337
}
329338
}
@@ -340,25 +349,26 @@ function applyCommandsInner<State>(
340349

341350
result.push(...fns);
342351

352+
const compiled = compileMiddlewares(result, onError);
343353
const resPath = toRoutePath(mergePath(basePath, pattern, false));
344354
if (method === "ALL") {
345-
router.add("GET", resPath, result);
346-
router.add("DELETE", resPath, result);
347-
router.add("HEAD", resPath, result);
348-
router.add("OPTIONS", resPath, result);
349-
router.add("PATCH", resPath, result);
350-
router.add("POST", resPath, result);
351-
router.add("PUT", resPath, result);
355+
router.add("GET", resPath, compiled);
356+
router.add("DELETE", resPath, compiled);
357+
router.add("HEAD", resPath, compiled);
358+
router.add("OPTIONS", resPath, compiled);
359+
router.add("PATCH", resPath, compiled);
360+
router.add("POST", resPath, compiled);
361+
router.add("PUT", resPath, compiled);
352362
} else {
353-
router.add(method, resPath, result);
363+
router.add(method, resPath, compiled);
354364
}
355365

356366
break;
357367
}
358368
case CommandType.FsRoute: {
359369
const items = cmd.getItems();
360370
const base = mergePath(basePath, cmd.pattern, true);
361-
applyCommandsInner(root, router, items, base);
371+
applyCommandsInner(root, router, items, base, onError);
362372
break;
363373
}
364374
default:

packages/fresh/src/middlewares/csp.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -82,19 +82,26 @@ export function csp<State>(options: CSPOptions = {}): Middleware<State> {
8282
"upgrade-insecure-requests",
8383
];
8484

85-
const cspDirectives = [...defaultCsp, ...csp];
85+
// User-provided directives override defaults with the same name
86+
const userDirectiveNames = new Set(
87+
csp.map((d) => d.split(" ")[0]),
88+
);
89+
const merged = defaultCsp.filter((d) =>
90+
!userDirectiveNames.has(d.split(" ")[0])
91+
);
92+
merged.push(...csp);
93+
8694
if (reportTo) {
87-
cspDirectives.push(`report-to csp-endpoint`);
88-
cspDirectives.push(`report-uri ${reportTo}`); // deprecated but some browsers still use it
95+
merged.push(`report-to csp-endpoint`);
96+
merged.push(`report-uri ${reportTo}`); // deprecated but some browsers still use it
8997
}
90-
9198
const headerName = reportOnly
9299
? "Content-Security-Policy-Report-Only"
93100
: "Content-Security-Policy";
94101

95102
if (!useNonce) {
96103
// Static CSP — no per-request nonce
97-
const cspString = cspDirectives.join("; ");
104+
const cspString = merged.join("; ");
98105
return async (ctx) => {
99106
const res = await ctx.next();
100107
res.headers.set(headerName, cspString);
@@ -113,7 +120,7 @@ export function csp<State>(options: CSPOptions = {}): Middleware<State> {
113120

114121
let directives: string[];
115122
if (nonce) {
116-
directives = cspDirectives.map((d) => {
123+
directives = merged.map((d) => {
117124
const spaceIdx = d.indexOf(" ");
118125
const name = spaceIdx === -1 ? d : d.slice(0, spaceIdx);
119126
if (INLINE_DIRECTIVES.has(name) && d.includes("'unsafe-inline'")) {
@@ -122,7 +129,7 @@ export function csp<State>(options: CSPOptions = {}): Middleware<State> {
122129
return d;
123130
});
124131
} else {
125-
directives = cspDirectives;
132+
directives = merged;
126133
}
127134

128135
res.headers.set(headerName, directives.join("; "));

0 commit comments

Comments
 (0)