swup 4.3.4 → 4.4.0

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "swup",
3
3
  "amdName": "Swup",
4
- "version": "4.3.4",
4
+ "version": "4.4.0",
5
5
  "description": "Versatile and extensible page transition library for server-rendered websites",
6
6
  "type": "module",
7
7
  "source": "./src/Swup.ts",
@@ -35,17 +35,14 @@
35
35
  "postinstall": "opencollective-postinstall || true",
36
36
  "test": "npm run test:unit && npm run test:e2e",
37
37
  "test:ci": "npm run test:unit && npm run test:e2e:ci",
38
- "test:unit": "vitest run --config ./vitest/vitest.config.ts",
39
- "test:unit:watch": "vitest --config ./vitest/vitest.config.ts",
40
- "test:e2e": "start-server-and-test test:e2e:start 8274 cy:run",
41
- "test:e2e:ci": "start-server-and-test test:e2e:start 8274 cy:run:record",
42
- "test:e2e:dev": "start-server-and-test test:e2e:start 8274 cy:open",
43
- "test:e2e:instrument": "nyc instrument --compact=false dist cypress/fixtures/dist",
44
- "test:e2e:server": "http-server --silent --port 8274 cypress/fixtures",
45
- "test:e2e:start": "npm run test:e2e:instrument && npm run test:e2e:server",
46
- "cy:run": "cypress run",
47
- "cy:run:record": "cypress run --record",
48
- "cy:open": "cypress open",
38
+ "test:unit": "vitest run --config ./tests/config/vitest.config.ts",
39
+ "test:unit:watch": "vitest --config ./tests/config/vitest.config.ts",
40
+ "test:e2e": "npx playwright test --config ./tests/config/playwright.config.ts",
41
+ "test:e2e:dev": "npx playwright test --ui --config ./tests/config/playwright.config.ts",
42
+ "test:e2e:install": "npx playwright install --with-deps",
43
+ "test:e2e:instrument": "nyc instrument --compact=false dist tests/fixtures/dist",
44
+ "test:e2e:serve": "npx serve -n -S -L -p 8274 --config ./tests/config/serve.json",
45
+ "test:e2e:start": "npm run test:e2e:instrument && npm run test:e2e:serve",
49
46
  "prepare": "husky install"
50
47
  },
51
48
  "author": "Georgy Marchuk",
@@ -67,25 +64,23 @@
67
64
  },
68
65
  "devDependencies": {
69
66
  "@babel/preset-typescript": "^7.18.6",
70
- "@cypress/code-coverage": "^3.10.0",
67
+ "@playwright/test": "^1.37.1",
71
68
  "@swup/browserslist-config": "^1.0.0",
72
69
  "@swup/prettier-config": "^1.0.0",
73
70
  "@types/jsdom": "^21.1.1",
74
71
  "@typescript-eslint/eslint-plugin": "^6.3.0",
75
72
  "@typescript-eslint/parser": "^6.3.0",
76
- "cypress": "^12.3.0",
77
73
  "eslint": "^8.46.0",
78
74
  "eslint-config-prettier": "^9.0.0",
79
75
  "eslint-plugin-prettier": "^4.2.1",
80
- "http-server": "^14.1.1",
81
76
  "husky": "^8.0.0",
82
77
  "istanbul-lib-coverage": "^3.2.0",
83
78
  "jsdom": "^22.1.0",
84
79
  "microbundle": "^0.15.0",
85
80
  "nyc": "^15.1.0",
86
81
  "prettier": "^2.8.2",
87
- "start-server-and-test": "^2.0.0",
88
- "vitest": "^0.31.2"
82
+ "serve": "^14.2.1",
83
+ "vitest": "^0.34.3"
89
84
  },
90
85
  "collective": {
91
86
  "type": "opencollective",
package/src/Swup.ts CHANGED
@@ -49,6 +49,8 @@ export type Options = {
49
49
  resolveUrl: (url: string) => string;
50
50
  /** Callback for telling swup to ignore certain popstate events. */
51
51
  skipPopStateHandling: (event: PopStateEvent) => boolean;
52
+ /** Request timeout in milliseconds. */
53
+ timeout: number;
52
54
  };
53
55
 
54
56
  const defaults: Options = {
@@ -66,7 +68,8 @@ const defaults: Options = {
66
68
  'X-Requested-With': 'swup',
67
69
  'Accept': 'text/html, application/xhtml+xml'
68
70
  },
69
- skipPopStateHandling: (event) => (event.state as HistoryState)?.source !== 'swup'
71
+ skipPopStateHandling: (event) => (event.state as HistoryState)?.source !== 'swup',
72
+ timeout: 0
70
73
  };
71
74
 
72
75
  /** Swup page transition library. */
@@ -21,6 +21,7 @@ export interface HookDefinitions {
21
21
  'disable': undefined;
22
22
  'fetch:request': { url: string; options: FetchOptions };
23
23
  'fetch:error': { url: string; status: number; response: Response };
24
+ 'fetch:timeout': { url: string };
24
25
  'history:popstate': { event: PopStateEvent };
25
26
  'link:click': { el: HTMLAnchorElement; event: DelegateEvent<MouseEvent> };
26
27
  'link:self': undefined;
@@ -31,6 +32,7 @@ export interface HookDefinitions {
31
32
  'scroll:top': { options: ScrollIntoViewOptions };
32
33
  'scroll:anchor': { hash: string; options: ScrollIntoViewOptions };
33
34
  'visit:start': undefined;
35
+ 'visit:transition': undefined;
34
36
  'visit:end': undefined;
35
37
  }
36
38
 
@@ -40,6 +42,7 @@ export interface HookReturnValues {
40
42
  'page:load': Promise<PageData>;
41
43
  'scroll:top': boolean;
42
44
  'scroll:anchor': boolean;
45
+ 'visit:transition': Promise<boolean>;
43
46
  }
44
47
 
45
48
  export type HookArguments<T extends HookName> = HookDefinitions[T];
@@ -131,6 +134,7 @@ export class Hooks {
131
134
  'disable',
132
135
  'fetch:request',
133
136
  'fetch:error',
137
+ 'fetch:timeout',
134
138
  'history:popstate',
135
139
  'link:click',
136
140
  'link:self',
@@ -141,6 +145,7 @@ export class Hooks {
141
145
  'scroll:top',
142
146
  'scroll:anchor',
143
147
  'visit:start',
148
+ 'visit:transition',
144
149
  'visit:end'
145
150
  ];
146
151
 
@@ -15,16 +15,25 @@ export interface FetchOptions extends Omit<RequestInit, 'cache'> {
15
15
  method?: 'GET' | 'POST';
16
16
  /** The body of the request: raw string, form data object or URL params. */
17
17
  body?: string | FormData | URLSearchParams;
18
+ /** The request timeout in milliseconds. */
19
+ timeout?: number;
18
20
  }
19
21
 
20
22
  export class FetchError extends Error {
21
23
  url: string;
22
- status: number;
23
- constructor(message: string, details: { url: string; status: number }) {
24
+ status?: number;
25
+ aborted: boolean;
26
+ timedOut: boolean;
27
+ constructor(
28
+ message: string,
29
+ details: { url: string; status?: number; aborted?: boolean; timedOut?: boolean }
30
+ ) {
24
31
  super(message);
25
32
  this.name = 'FetchError';
26
33
  this.url = details.url;
27
34
  this.status = details.status;
35
+ this.aborted = details.aborted || false;
36
+ this.timedOut = details.timedOut || false;
28
37
  }
29
38
  }
30
39
 
@@ -39,14 +48,44 @@ export async function fetchPage(
39
48
  url = Location.fromUrl(url).url;
40
49
 
41
50
  const headers = { ...this.options.requestHeaders, ...options.headers };
42
- options = { ...options, headers };
51
+ const timeout = options.timeout ?? this.options.timeout;
52
+ const controller = new AbortController();
53
+ const { signal } = controller;
54
+ options = { ...options, headers, signal };
55
+
56
+ let timedOut = false;
57
+ let timeoutId: ReturnType<typeof setTimeout> | null = null;
58
+ if (timeout && timeout > 0) {
59
+ timeoutId = setTimeout(() => {
60
+ timedOut = true;
61
+ controller.abort('timeout');
62
+ }, timeout);
63
+ }
43
64
 
44
65
  // Allow hooking before this and returning a custom response-like object (e.g. custom fetch implementation)
45
- const response: Response = await this.hooks.call(
46
- 'fetch:request',
47
- { url, options },
48
- (visit, { url, options }) => fetch(url, options)
49
- );
66
+ let response: Response;
67
+ try {
68
+ response = await this.hooks.call(
69
+ 'fetch:request',
70
+ { url, options },
71
+ (visit, { url, options }) => fetch(url, options)
72
+ );
73
+ if (timeoutId) {
74
+ clearTimeout(timeoutId);
75
+ }
76
+ } catch (error) {
77
+ if (timedOut) {
78
+ this.hooks.call('fetch:timeout', { url });
79
+ throw new FetchError(`Request timed out: ${url}`, { url, timedOut });
80
+ }
81
+ if ((error as Error)?.name === 'AbortError' || signal.aborted) {
82
+ throw new FetchError(`Request aborted: ${url}`, {
83
+ url: url,
84
+ aborted: true
85
+ });
86
+ }
87
+ throw error;
88
+ }
50
89
 
51
90
  const { status, url: responseUrl } = response;
52
91
  const html = await response.text();
@@ -1,6 +1,6 @@
1
1
  import Swup from '../Swup.js';
2
2
  import { createHistoryRecord, updateHistoryRecord, getCurrentUrl, Location } from '../helpers.js';
3
- import { FetchOptions, PageData } from './fetchPage.js';
3
+ import { FetchError, FetchOptions, PageData } from './fetchPage.js';
4
4
  import { VisitInitOptions } from './Visit.js';
5
5
 
6
6
  export type HistoryAction = 'push' | 'replace';
@@ -137,20 +137,27 @@ export async function performNavigation(
137
137
  visit.to.html = html;
138
138
  }
139
139
 
140
- // Wait for page to load and leave animation to finish
141
- const animationPromise = this.animatePageOut();
142
- const [page] = await Promise.all([pagePromise, animationPromise]);
140
+ // perform the actual transition: animate and replace content
141
+ await this.hooks.call('visit:transition', undefined, async (visit) => {
142
+ // Start leave animation
143
+ const animationPromise = this.animatePageOut();
143
144
 
144
- // Abort if another visit was started in the meantime
145
- if (visit.id !== this.visit.id) {
146
- return;
147
- }
145
+ // Wait for page to load and leave animation to finish
146
+ const [page] = await Promise.all([pagePromise, animationPromise]);
147
+
148
+ // Abort if another visit was started in the meantime
149
+ if (visit.id !== this.visit.id) {
150
+ return false;
151
+ }
148
152
 
149
- // Render page: replace content and scroll to top/fragment
150
- await this.renderPage(page);
153
+ // Render page: replace content and scroll to top/fragment
154
+ await this.renderPage(page);
151
155
 
152
- // Wait for enter animation
153
- await this.animatePageIn();
156
+ // Wait for enter animation
157
+ await this.animatePageIn();
158
+
159
+ return true;
160
+ });
154
161
 
155
162
  // Finalize visit
156
163
  await this.hooks.call('visit:end', undefined, () => this.classes.clear());
@@ -159,9 +166,9 @@ export async function performNavigation(
159
166
  // if (visit.to && this.isSameResolvedUrl(visit.to.url, requestedUrl)) {
160
167
  // this.visit = this.createVisit({ to: undefined });
161
168
  // }
162
- } catch (error: unknown) {
163
- // Return early if error is undefined (probably aborted preload request)
164
- if (!error) {
169
+ } catch (error) {
170
+ // Return early if error is undefined or signals an aborted request
171
+ if (!error || (error as FetchError)?.aborted) {
165
172
  return;
166
173
  }
167
174
 
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1 +0,0 @@
1
- export {};
@@ -1,83 +0,0 @@
1
- import { DelegateEvent } from 'delegate-it';
2
- import { describe, expect, it, vi } from 'vitest';
3
-
4
- import pckg from '../../package.json';
5
- import Swup, { Options, Plugin } from '../index.js';
6
- import * as SwupTS from '../Swup.js';
7
-
8
- const baseUrl = window.location.origin;
9
-
10
- describe('Exports', () => {
11
- it('should export Swup and Options/Plugin types', () => {
12
- class SwupPlugin implements Plugin {
13
- name = 'SwupPlugin';
14
- isSwupPlugin = true as const;
15
- mount = () => {};
16
- unmount = () => {};
17
- }
18
-
19
- const options: Partial<Options> = {
20
- animateHistoryBrowsing: false,
21
- animationSelector: '[class*="transition-"]',
22
- cache: true,
23
- containers: ['#swup'],
24
- ignoreVisit: (url, { el } = {}) => !!el?.closest('[data-no-swup]'),
25
- linkSelector: 'a[href]',
26
- plugins: [new SwupPlugin()],
27
- resolveUrl: (url) => url,
28
- requestHeaders: {
29
- 'X-Requested-With': 'swup',
30
- 'Accept': 'text/html, application/xhtml+xml'
31
- },
32
- skipPopStateHandling: (event) => event.state?.source !== 'swup'
33
- };
34
-
35
- const swup = new Swup(options);
36
- expect(swup).toBeInstanceOf(Swup);
37
- });
38
-
39
- it('should define a version', () => {
40
- const swup = new Swup();
41
- expect(swup.version).not.toBeUndefined();
42
- expect(swup.version).toEqual(pckg.version);
43
- });
44
-
45
- it('UMD compatibility: Swup.ts should only have a default export', () => {
46
- expect(Object.keys(SwupTS)).toEqual(['default']);
47
- });
48
- });
49
-
50
- describe('ignoreVisit', () => {
51
- it('should be called with relative URL', () => {
52
- const ignoreVisit = vi.fn(() => true);
53
- const swup = new Swup({ ignoreVisit });
54
- swup.shouldIgnoreVisit(`${baseUrl}/path/?query#hash`);
55
-
56
- expect(ignoreVisit.mock.calls).toHaveLength(1);
57
- expect((ignoreVisit.mock.lastCall as any)[0]).toEqual('/path/?query#hash');
58
- });
59
-
60
- it('should have access to element and event params', () => {
61
- const el = document.createElement('a');
62
- el.href = `${baseUrl}/path/?query#hash`;
63
- const event = new MouseEvent('click') as DelegateEvent<MouseEvent>;
64
- event.delegateTarget = el;
65
-
66
- const ignoreVisit = vi.fn(() => true);
67
- const swup = new Swup({ ignoreVisit });
68
- swup.navigate(el.href, {}, { el, event });
69
-
70
- expect(ignoreVisit.mock.calls).toHaveLength(1);
71
- expect((ignoreVisit.mock.lastCall as any)[1]).toEqual(
72
- expect.objectContaining({ el, event })
73
- );
74
- });
75
-
76
- it('should be called from visit method', () => {
77
- const ignoreVisit = vi.fn(() => true);
78
- const swup = new Swup({ ignoreVisit });
79
- swup.navigate('/path/');
80
-
81
- expect(ignoreVisit.mock.calls).toHaveLength(1);
82
- });
83
- });
@@ -1,54 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { matchPath } from '../../index.js';
3
- import { pathToRegexp } from 'path-to-regexp';
4
-
5
- describe('matchPath', () => {
6
- it('should return false if not matching', () => {
7
- const urlMatch = matchPath('/users/:user');
8
- const match = urlMatch('/posts/');
9
- expect(match).toBe(false);
10
- });
11
-
12
- it('should return an object if matching', () => {
13
- const urlMatch = matchPath('/users/:user');
14
- const match = urlMatch('/users/bob');
15
- expect(match).toEqual({
16
- path: '/users/bob',
17
- index: 0,
18
- params: { user: 'bob' }
19
- });
20
- });
21
-
22
- it('should work with primitive strings', () => {
23
- const urlMatch = matchPath<{ user: string }>('/users/:user');
24
- const match = urlMatch('/users/bob');
25
- const params = !match ? false : match.params;
26
- expect(params).toEqual({ user: 'bob' });
27
- });
28
-
29
- it('should work with an array of paths', () => {
30
- const urlMatch = matchPath<{ user: string }>(['/users/', '/users/:user']);
31
-
32
- const { params: withParams } = urlMatch('/users/bob') || {};
33
- expect(withParams).toEqual({ user: 'bob' });
34
-
35
- const { params: withoutParams } = urlMatch('/users/') || {};
36
- expect(withoutParams).toEqual({});
37
- });
38
-
39
- /**
40
- * When passing a regex to `match`, the params in the response are sorted by appearance.
41
- * Only helpful for falsy/truthy detection
42
- */
43
- it('should work with regex', () => {
44
- const re = pathToRegexp('/users/:user');
45
- const urlMatch = matchPath(re);
46
- const { params } = urlMatch('/users/bob') || {};
47
- expect(params).toEqual({ '0': 'bob' });
48
- });
49
-
50
- it('should throw with malformed paths', () => {
51
- // prettier-ignore
52
- expect(() => matchPath('/\?user=:user')).toThrowError('[swup] Error parsing path');
53
- });
54
- });
@@ -1,159 +0,0 @@
1
- import { beforeEach, describe, expect, it, vi } from 'vitest';
2
- import Swup from '../../Swup.js';
3
- import { Cache, CacheData } from '../Cache.js';
4
- import { Visit } from '../Visit.js';
5
-
6
- interface CacheTtlData {
7
- ttl: number;
8
- created: number;
9
- }
10
-
11
- interface CacheIndexData {
12
- index: number;
13
- }
14
-
15
- interface AugmentedCacheData extends CacheData, CacheTtlData, CacheIndexData {}
16
-
17
- const swup = new Swup();
18
- const visit = swup.visit;
19
- const cache = new Cache(swup);
20
-
21
- const page1 = { url: '/page-1', html: '1' };
22
- const page2 = { url: '/page-2', html: '2' };
23
- const page3 = { url: '/page-3', html: '3' };
24
-
25
- describe('Cache', () => {
26
- beforeEach(() => {
27
- cache.clear();
28
- });
29
-
30
- it('should be empty', () => {
31
- expect(cache.size).toBe(0);
32
- });
33
-
34
- it('should append pages', () => {
35
- cache.set(page1.url, page1);
36
- expect(cache.size).toBe(1);
37
- });
38
-
39
- it('should have pages', () => {
40
- cache.set(page1.url, page1);
41
- expect(cache.has(page1.url)).toBe(true);
42
- });
43
-
44
- it('should get pages', () => {
45
- cache.set(page1.url, page1);
46
- expect(cache.get(page1.url)).toEqual(page1);
47
- });
48
-
49
- it('should delete pages', () => {
50
- cache.set(page1.url, page1);
51
- expect(cache.has(page1.url)).toBe(true);
52
- cache.delete(page1.url);
53
- expect(cache.has(page1.url)).toBe(false);
54
- });
55
-
56
- it('should clear', () => {
57
- cache.set(page1.url, page1);
58
- expect(cache.size).toBe(1);
59
- cache.clear();
60
- expect(cache.size).toBe(0);
61
- });
62
-
63
- it('should overwrite identical pages', () => {
64
- cache.set(page1.url, page1);
65
- expect(cache.size).toBe(1);
66
- cache.set(page1.url, page1);
67
- expect(cache.size).toBe(1);
68
- });
69
-
70
- it('should not overwrite different pages', () => {
71
- cache.set(page1.url, page1);
72
- expect(cache.size).toBe(1);
73
- cache.set(page2.url, page2);
74
- expect(cache.size).toBe(2);
75
- });
76
-
77
- it('should trigger a hook on set', () => {
78
- const handler = vi.fn();
79
-
80
- swup.hooks.on('cache:set', handler);
81
-
82
- cache.set(page1.url, page1);
83
-
84
- expect(handler).toBeCalledTimes(1);
85
- expect(handler).toBeCalledWith(visit, { page: page1 }, undefined);
86
- });
87
-
88
- it('should allow augmenting cache entries on save', () => {
89
- const now = Date.now();
90
-
91
- swup.hooks.on('cache:set', (_, { page }) => {
92
- const ttl: CacheTtlData = { ttl: 1000, created: now };
93
- cache.update(page.url, ttl);
94
- });
95
-
96
- cache.set('/page', { url: '/page', html: '' });
97
-
98
- const page = cache.get('/page');
99
-
100
- expect(page).toEqual({ url: '/page', html: '', ttl: 1000, created: now });
101
- });
102
-
103
- it('should allow manual pruning', () => {
104
- swup.hooks.on('cache:set', (_, { page }) => {
105
- cache.update(page.url, { index: cache.size });
106
- });
107
-
108
- cache.set(page1.url, page1);
109
- cache.set(page2.url, page2);
110
- cache.set(page3.url, page3);
111
-
112
- cache.prune((url, page) => (page as AugmentedCacheData).index > 2);
113
-
114
- expect(cache.size).toBe(2);
115
- expect(cache.has(page1.url)).toBe(true);
116
- expect(cache.has(page2.url)).toBe(true);
117
- expect(cache.has(page3.url)).toBe(false);
118
- });
119
-
120
- it('should return a copy from cache.get()', () => {
121
- cache.set(page1.url, page1);
122
- const page = cache.get(page1.url);
123
- page!.html = 'new';
124
- expect(cache.get(page1.url)?.html).toEqual(page1.html);
125
- });
126
-
127
- it('should return a new Map with shallow copies from cache.all', () => {
128
- cache.set(page1.url, page1);
129
- cache.set(page2.url, page2);
130
-
131
- const all = cache.all;
132
- all.get(page1.url)!.html = 'new';
133
-
134
- expect(cache.get(page1.url)?.html).toEqual(page1.html);
135
- });
136
- });
137
-
138
- describe('Types', () => {
139
- it('error when necessary', async () => {
140
- const swup = new Swup();
141
- const cache = new Cache(swup);
142
-
143
- // @ts-expect-no-error
144
- swup.hooks.on('history:popstate', (visit: Visit, { event: PopStateEvent }) => {});
145
- // @ts-expect-no-error
146
- await swup.hooks.call('history:popstate', { event: new PopStateEvent('') });
147
-
148
- try {
149
- // @ts-expect-error
150
- cache.set();
151
- // @ts-expect-error
152
- cache.set(url);
153
- // @ts-expect-error
154
- cache.set(url, {});
155
- // @ts-expect-error
156
- cache.set({ url: '/test' });
157
- } catch (error) {}
158
- });
159
- });
@@ -1,36 +0,0 @@
1
- import { DelegateEvent } from 'delegate-it';
2
- import { describe, it } from 'vitest';
3
-
4
- import { delegateEvent } from '../../helpers/delegateEvent.js';
5
-
6
- describe('delegateEvent', () => {
7
- it('should return correct types', () => {
8
- delegateEvent('form', 'submit', (event) => {});
9
-
10
- // @ts-expect-no-error
11
- delegateEvent('form', 'submit', (event: SubmitEvent) => {});
12
- // @ts-expect-error
13
- delegateEvent('form', 'submit', (event: MouseEvent) => {});
14
-
15
- // @ts-expect-no-error
16
- delegateEvent('form', 'submit', (event: DelegateEvent<SubmitEvent>) => {});
17
- // @ts-expect-error
18
- delegateEvent('form', 'submit', (event: DelegateEvent<MouseEvent>) => {});
19
-
20
- // @ts-expect-no-error
21
- delegateEvent('form', 'submit', (event: DelegateEvent<SubmitEvent, HTMLFormElement>) => {});
22
- delegateEvent(
23
- 'form',
24
- 'submit',
25
- // @ts-expect-error
26
- (event: DelegateEvent<MouseEvent, HTMLAnchorElement>) => {}
27
- );
28
-
29
- delegateEvent('form', 'submit', (event) => {
30
- // @ts-expect-no-error
31
- const el: HTMLFormElement = event.delegateTarget;
32
- });
33
- // @ts-expect-error
34
- delegateEvent('form', 'submit', (event: MouseEvent) => {});
35
- });
36
- });