crelte 0.2.0 → 0.2.2

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.
@@ -2,7 +2,7 @@ import Site, { SiteFromGraphQl } from './Site.js';
2
2
  import History from './History.js';
3
3
  import { ClientHistory, ServerHistory } from './History.js';
4
4
  import Request, { isRequest, RequestOptions } from './Request.js';
5
- import Route, { RouteOptions } from './Route.js';
5
+ import Route from './Route.js';
6
6
 
7
7
  export type InnerRouterOpts = {
8
8
  preloadOnMouseOver: boolean;
@@ -66,14 +66,9 @@ export default class InnerRouter {
66
66
  req._fillFromState(window.history.state);
67
67
 
68
68
  req.origin = 'init';
69
-
70
- if (req.search.get('x-craft-live-preview')) {
71
- req.origin = 'live-preview-init';
72
- }
73
-
74
69
  window.history.scrollRestoration = 'manual';
75
70
 
76
- this.open(req, {}, false);
71
+ this.setRoute(req);
77
72
  }
78
73
 
79
74
  /**
@@ -139,9 +134,10 @@ export default class InnerRouter {
139
134
  }
140
135
 
141
136
  /**
142
- * Resolve a url or Route and convert it to a Route
137
+ * Resolve a url or Route and convert it to a Request
143
138
  *
144
139
  * @param target
140
+ * @param opts, any option present will override the value in target
145
141
  * @return Returns null if the url does not match our host (the protocol get's ignored)
146
142
  */
147
143
  targetToRequest(
@@ -221,12 +217,20 @@ export default class InnerRouter {
221
217
 
222
218
  e.preventDefault();
223
219
 
224
- const route = this.routeFromUrl(link.href);
225
- if (this.route?.eq(route)) return;
226
-
227
- route.origin = 'click';
220
+ const req = this.targetToRequest(link.href, { origin: 'click' });
221
+ const routeEq =
222
+ this.route && this.route.eqUrl(req) && this.route.eqSearch(req);
223
+ // the route is the same don't do anything
224
+ // or maybe scroll the page to the hash? todo
225
+ if (routeEq && this.route?.eqHash(req)) return;
226
+
227
+ // this means the hash did not match, so we wan't to just scroll but not load
228
+ // data
229
+ if (routeEq) {
230
+ req.disableLoadData = true;
231
+ }
228
232
 
229
- this.open(route);
233
+ this.open(req);
230
234
  });
231
235
 
232
236
  if (this.preloadOnMouseOver) {
@@ -270,7 +274,7 @@ export default class InnerRouter {
270
274
  // use the latest state
271
275
  this.history.replaceState(this.route._toState());
272
276
 
273
- if (current.origin === 'live-preview-init') {
277
+ if (current.inLivePreview()) {
274
278
  sessionStorage.setItem(
275
279
  'live-preview-scroll',
276
280
  // use the latest scrollY
@@ -286,31 +290,28 @@ export default class InnerRouter {
286
290
  window.addEventListener('popstate', async e => {
287
291
  if (!('route' in e.state)) return;
288
292
 
289
- const route = this.targetToRequest(window.location.href);
290
- route._fillFromState(e.state);
291
- route.origin = 'pop';
292
-
293
- // since the pop event replaced our state we can't replace the state
294
- // for the scrollY in our open call so we just clear the current
295
- // route since it is now already the new route
296
- this.route = null;
293
+ const req = this.targetToRequest(window.location.href);
294
+ req._fillFromState(e.state);
295
+ req.origin = 'pop';
297
296
 
298
- this.open(route, {}, false);
297
+ this.setRoute(req);
299
298
  });
300
299
  }
301
300
 
302
301
  /**
303
- * Open's a route
302
+ * Open a new route
304
303
  *
305
304
  * @param route a route object or an url or uri, never input the same route object again
306
305
  * @param pushState if true pushed the state to the window.history
306
+ *
307
+ * ## Important
308
+ * Make sure a req always has the correct origin,
309
+ * `push` and `replace` will cause this function to throw an error
307
310
  */
308
- open(
309
- target: string | URL | Route | Request,
310
- opts: RouteOptions = {},
311
- pushState: boolean = true,
312
- ) {
313
- const req = this.targetToRequest(target, opts);
311
+ open(req: Request) {
312
+ if (['push', 'replace'].includes(req.origin)) {
313
+ throw new Error('Do not use open with push or replace');
314
+ }
314
315
 
315
316
  const current = this.route;
316
317
  if (current) {
@@ -340,14 +341,10 @@ export default class InnerRouter {
340
341
  return;
341
342
  }
342
343
 
343
- if (pushState) {
344
- req.index = (current?.index ?? 0) + 1;
345
- this.onRoute(req, () => {
346
- this.pushState(req.toRoute());
347
- });
348
- } else {
349
- this.setRoute(req);
350
- }
344
+ req.index = (current?.index ?? 0) + 1;
345
+ this.onRoute(req, () => {
346
+ this.push(req, true);
347
+ });
351
348
  }
352
349
 
353
350
  /**
@@ -358,47 +355,70 @@ export default class InnerRouter {
358
355
  *
359
356
  * @param req
360
357
  */
361
- setRoute(req: Request) {
358
+ setRoute(req: Request, preventOnRoute = false) {
362
359
  this.route = req.toRoute();
363
360
 
364
- this.onRoute(req, () => {});
361
+ if (!preventOnRoute) this.onRoute(req, () => {});
365
362
  }
366
363
 
367
364
  /**
368
- * This pushes the state of the route without triggering a currentRoute
369
- * or currentSiteId change
365
+ * This pushes a new route to the history
370
366
  *
371
- * You can use when using pagination for example change the route object
372
- * (search argument) and then call pushState
367
+ * @param req, never input the same route object again
373
368
  *
374
- * @param route, never input the same route object again
369
+ * ## Important
370
+ * Make sure the route has the correct origin
375
371
  */
376
- pushState(route: Route) {
377
- const url = route.url;
372
+ push(req: Request, preventOnRoute = false) {
373
+ const url = req.url;
374
+ // todo a push should also store the previous scrollY
375
+
376
+ let nReq = req;
377
+ if (req.scrollY === null) {
378
+ // if there is no scrollY stored we store the current scrollY
379
+ // since a push does not cause a scroll top
380
+ // todo: probably should refactor something probably
381
+ // should not be here
382
+ nReq = req.clone();
383
+ nReq.scrollY = this.history.scrollY();
384
+ }
378
385
 
379
386
  this.history.pushState(
380
- route._toState(),
387
+ nReq._toState(),
381
388
  url.pathname + url.search + url.hash,
382
389
  );
383
390
 
384
- this.route = route;
391
+ this.setRoute(req, preventOnRoute);
385
392
  }
386
393
 
387
394
  /**
388
- * This replaces the state of the route without triggering a currentRoute
389
- * or currentSiteId change
395
+ * This replaces the current route
390
396
  *
391
- * @param route, never input the same route object again
397
+ * @param req, never input the same route object again
398
+ *
399
+ * ## Important
400
+ * Make sure the route has the correct origin
392
401
  */
393
- replaceState(route: Route) {
394
- const url = route.url;
402
+ replace(req: Request) {
403
+ const url = req.url;
404
+
405
+ let nReq = req;
406
+ if (req.scrollY === null) {
407
+ // if there is no scrollY stored we store the current scrollY
408
+ // since a replace does not cause a scrollTo and we wan't
409
+ // history back to work as intended
410
+ // todo: probably should refactor something probably
411
+ // should not be here
412
+ nReq = req.clone();
413
+ nReq.scrollY = this.history.scrollY();
414
+ }
395
415
 
396
416
  this.history.replaceState(
397
- route._toState(),
417
+ nReq._toState(),
398
418
  url.pathname + url.search + url.hash,
399
419
  );
400
420
 
401
- this.route = route;
421
+ this.setRoute(req);
402
422
  }
403
423
 
404
424
  /**
@@ -430,7 +450,7 @@ export default class InnerRouter {
430
450
 
431
451
  // if the route is a live preview init and we have a scrollY stored
432
452
  // scroll to that
433
- if (req.origin === 'live-preview-init') {
453
+ if (req.inLivePreview()) {
434
454
  const scrollY = sessionStorage.getItem('live-preview-scroll');
435
455
  if (scrollY) {
436
456
  scrollTo = {
@@ -465,6 +485,10 @@ export default class InnerRouter {
465
485
  };
466
486
  }
467
487
 
488
+ // make sure push and replace don't cause a scroll if it is not intended
489
+ if (!scrollTo && (req.origin === 'push' || req.origin === 'replace'))
490
+ return;
491
+
468
492
  // scroll to the top if nothing else matches
469
493
  if (!scrollTo) {
470
494
  scrollTo = {
@@ -10,6 +10,7 @@ export type RequestOptions = {
10
10
  index?: number;
11
11
  origin?: RouteOrigin;
12
12
  disableScroll?: boolean;
13
+ disableLoadData?: boolean;
13
14
  statusCode?: number;
14
15
  };
15
16
 
@@ -25,6 +26,12 @@ export default class Request extends Route {
25
26
  */
26
27
  disableScroll: boolean;
27
28
 
29
+ /**
30
+ * Disable loading data
31
+ * @default false
32
+ */
33
+ disableLoadData: boolean;
34
+
28
35
  /**
29
36
  * The Status code that should be returned for a redirect
30
37
  */
@@ -40,6 +47,7 @@ export default class Request extends Route {
40
47
  super(url, site, opts);
41
48
 
42
49
  this.disableScroll = opts.disableScroll ?? false;
50
+ this.disableLoadData = opts.disableLoadData ?? false;
43
51
  this.statusCode = opts.statusCode ?? null;
44
52
  this._renderBarrier = new RenderBarrier();
45
53
  }
@@ -13,23 +13,25 @@ export type RouteOptions = {
13
13
  *
14
14
  * - `'init'`: is set on the first page load
15
15
  * - `'manual'`: is set when a route is triggered manually via `Router.open`
16
- * - `'live-preview-init'`: is set on the first page load in live preview mode
17
16
  * - `'click'`: is set when a route is triggered by a click event
18
17
  * - `'pop'`: is set when a route is triggered by a popstate event (back/forward)
18
+ * - `'replace'`: is set when a route is replaced via `Router.replaceState`
19
+ * - `'push'`: is set when a route is pushed via `Router.pushState`
20
+ *
21
+ * ## Note
22
+ * `replace` and `push` will not call loadData
19
23
  */
20
24
  export type RouteOrigin =
21
25
  | 'init'
22
- | 'live-preview-init'
23
26
  | 'manual'
24
27
  | 'click'
25
- | 'pop';
28
+ | 'pop'
29
+ | 'replace'
30
+ | 'push';
26
31
 
27
32
  /**
28
33
  * A Route contains information about the current page for example the url and
29
- * the site id
30
- *
31
- * ## Note
32
- * Never update the route directly, clone it before
34
+ * the site
33
35
  */
34
36
  export default class Route {
35
37
  /**
@@ -51,6 +53,13 @@ export default class Route {
51
53
 
52
54
  /**
53
55
  * The scroll position of the current route
56
+ *
57
+ * ## Note
58
+ * This does not have to represent the current scroll position
59
+ * should more be used internally.
60
+ *
61
+ * It might be useful for a new request to specify the wanted
62
+ * scroll position
54
63
  */
55
64
  scrollY: number | null;
56
65
 
@@ -154,6 +163,51 @@ export default class Route {
154
163
  return this.url.hash;
155
164
  }
156
165
 
166
+ /**
167
+ * Checks if there are previous routes which would allow it to go back
168
+ */
169
+ canGoBack(): boolean {
170
+ return !!this.index;
171
+ }
172
+
173
+ /**
174
+ * Gets the search param
175
+ *
176
+ * ## Example
177
+ * ```
178
+ * const route = new Route('https://example.com/foo/bar/?a=1&b=2', null);
179
+ * console.log(route.getSearchParam('a')); // '1'
180
+ * ```
181
+ */
182
+ getSearchParam(key: string): string | null {
183
+ return this.search.get(key);
184
+ }
185
+
186
+ /**
187
+ * Sets the search param or removes it if the value is null or undefined
188
+ *
189
+ * ## Example
190
+ * ```
191
+ * const route = new Route('https://example.com/foo/bar/?a=1&b=2', null);
192
+ * route.setSearchParam('a', '3');
193
+ * console.log(route.url.href); // 'https://example.com/foo/bar/?a=3&b=2'
194
+ *
195
+ * route.setSearchParam('a', null);
196
+ * console.log(route.url.href); // 'https://example.com/foo/bar/?b=2'
197
+ * ```
198
+ */
199
+ setSearchParam(key: string, value?: string | number | null) {
200
+ if (typeof value !== 'undefined' && value !== null) {
201
+ this.search.set(key, value as string);
202
+ } else {
203
+ this.search.delete(key);
204
+ }
205
+ }
206
+
207
+ inLivePreview(): boolean {
208
+ return !!this.search.get('x-craft-live-preview');
209
+ }
210
+
157
211
  /**
158
212
  * Returns if the site matches the url
159
213
  */
@@ -176,7 +230,32 @@ export default class Route {
176
230
  * This checks all properties of the url but search params do not have to be
177
231
  * in the same order
178
232
  */
179
- eq(route: Route) {
233
+ eq(route: Route | null) {
234
+ return (
235
+ route &&
236
+ this.eqUrl(route) &&
237
+ this.eqSearch(route) &&
238
+ this.eqHash(route)
239
+ );
240
+ }
241
+
242
+ /**
243
+ * Checks if the route is equal to another route
244
+ *
245
+ * This does not check the search params or hash
246
+ */
247
+ eqUrl(route: Route | null) {
248
+ return (
249
+ route &&
250
+ this.url.pathname === route.url.pathname &&
251
+ this.url.origin === route.url.origin
252
+ );
253
+ }
254
+
255
+ /**
256
+ * Checks if the search params are equal to another route
257
+ */
258
+ eqSearch(route: Route | null) {
180
259
  const searchEq = (a: URLSearchParams, b: URLSearchParams) => {
181
260
  if (a.size !== b.size) return false;
182
261
 
@@ -191,54 +270,14 @@ export default class Route {
191
270
  .every(([[ak, av], [bk, bv]]) => ak === bk && av === bv);
192
271
  };
193
272
 
194
- return (
195
- route &&
196
- this.url.pathname === route.url.pathname &&
197
- this.url.origin === route.url.origin &&
198
- searchEq(this.search, route.search) &&
199
- this.hash === route.hash
200
- );
201
- }
202
-
203
- /**
204
- * Checks if there are previous routes which would allow it to go back
205
- */
206
- canGoBack(): boolean {
207
- return !!this.index;
208
- }
209
-
210
- /**
211
- * Gets the search param
212
- *
213
- * ## Example
214
- * ```
215
- * const route = new Route('https://example.com/foo/bar/?a=1&b=2', null);
216
- * console.log(route.getSearchParam('a')); // '1'
217
- * ```
218
- */
219
- getSearchParam(key: string): string | null {
220
- return this.search.get(key);
273
+ return route && searchEq(this.search, route.search);
221
274
  }
222
275
 
223
276
  /**
224
- * Sets the search param or removes it if the value is null or undefined
225
- *
226
- * ## Example
227
- * ```
228
- * const route = new Route('https://example.com/foo/bar/?a=1&b=2', null);
229
- * route.setSearchParam('a', '3');
230
- * console.log(route.url.href); // 'https://example.com/foo/bar/?a=3&b=2'
231
- *
232
- * route.setSearchParam('a', null);
233
- * console.log(route.url.href); // 'https://example.com/foo/bar/?b=2'
234
- * ```
277
+ * Checks if the hash is equal to another route
235
278
  */
236
- setSearchParam(key: string, value?: string | number | null) {
237
- if (typeof value !== 'undefined' && value !== null) {
238
- this.search.set(key, value as string);
239
- } else {
240
- this.search.delete(key);
241
- }
279
+ eqHash(route: Route | null) {
280
+ return route && this.hash === route.hash;
242
281
  }
243
282
 
244
283
  /**
@@ -14,7 +14,7 @@ export type RouterOptions = {
14
14
 
15
15
  const defaultRouterOpts = {
16
16
  preloadOnMouseOver: false,
17
- deubgTiming: false,
17
+ debugTiming: false,
18
18
  };
19
19
 
20
20
  type LoadedMore = {
@@ -35,6 +35,17 @@ type Internal = {
35
35
  ready: () => any,
36
36
  ) => void;
37
37
 
38
+ // onNothingLoaded get's called if the request did not load new Data
39
+ // since maybe a push or replace was called
40
+ onNothingLoaded: (
41
+ req: Request,
42
+ // call ready once your ready to update the dom
43
+ // this makes sure we trigger a route and site update
44
+ // almost at the same moment and probably the same tick
45
+ // to make sure we don't have any flickering
46
+ ready: () => void,
47
+ ) => void;
48
+
38
49
  onLoad: LoadFn;
39
50
 
40
51
  domReady: (req: Request) => void;
@@ -78,8 +89,6 @@ export default class Router {
78
89
  */
79
90
  private _loadingProgress: Writable<number>;
80
91
 
81
- private _onRouteEv: Listeners<[Route]>;
82
-
83
92
  private _onRequest: Listeners<[Request]>;
84
93
 
85
94
  /** @hidden */
@@ -105,13 +114,12 @@ export default class Router {
105
114
  this._loading = new Writable(false);
106
115
  this._loadingProgress = new Writable(0);
107
116
 
108
- this._onRouteEv = new Listeners();
109
-
110
117
  this._onRequest = new Listeners();
111
118
 
112
119
  this._internal = {
113
- onLoaded: () => {},
114
- onLoad: () => {},
120
+ onLoaded: () => { },
121
+ onNothingLoaded: () => { },
122
+ onLoad: () => { },
115
123
  domReady: req => this.inner.domReady(req),
116
124
  initClient: () => this._initClient(),
117
125
  initServer: (url, acceptLang) => this._initServer(url, acceptLang),
@@ -167,10 +175,13 @@ export default class Router {
167
175
  /**
168
176
  * Open a new route
169
177
  *
170
- * @param target the target to open can be an url or a route
178
+ * @param target the target to open can be an url, a route or a request
171
179
  * the url needs to start with http or with a / which will be considered as
172
180
  * the site baseUrl
173
181
  *
182
+ * ## Note
183
+ * The origin will always be set to 'manual'
184
+ *
174
185
  * ## Example
175
186
  * ```
176
187
  * import { getRouter } from 'crelte';
@@ -182,16 +193,25 @@ export default class Router {
182
193
  * // the following page will be opened https://example.com/de/foo/bar
183
194
  * ```
184
195
  */
185
- open(target: string | URL | Route, opts: RequestOptions = {}) {
186
- this.inner.open(target, opts);
196
+ open(target: string | URL | Route | Request, opts: RequestOptions = {}) {
197
+ const req = this.inner.targetToRequest(target, {
198
+ ...opts,
199
+ origin: 'manual',
200
+ });
201
+ this.inner.open(req);
187
202
  }
188
203
 
189
204
  /**
190
- * This pushes the state of the route without triggering an event
205
+ * This pushes the new route without triggering a new pageload
191
206
  *
192
207
  * You can use this when using pagination for example change the route object
193
208
  * (search argument) and then call pushState
194
209
  *
210
+ * ## Note
211
+ * This will always set the origin to 'push'
212
+ * And will clear the scrollY value if you not provide a new one via the `opts`
213
+ * This will disableLoadData by default if you not provide an override via the `opts`
214
+ *
195
215
  * ## Example
196
216
  * ```
197
217
  * import { getRouter } from 'crelte';
@@ -204,18 +224,37 @@ export default class Router {
204
224
  * router.pushState(route);
205
225
  * ```
206
226
  */
207
- pushState(route: Route) {
227
+ push(route: Route | Request, opts: RequestOptions = {}) {
228
+ // cancel previous request
208
229
  this.pageLoader.discard();
209
- this.inner.pushState(route);
230
+ const req = this.inner.targetToRequest(route, {
231
+ ...opts,
232
+ origin: 'push',
233
+ scrollY: opts.scrollY ?? undefined,
234
+ disableLoadData: opts.disableLoadData ?? true,
235
+ });
236
+ this.inner.push(req);
210
237
  this.destroyRequest();
211
238
  this.setNewRoute(route);
212
239
  }
213
240
 
241
+ /**
242
+ * @deprecated use push instead
243
+ */
244
+ pushState(route: Route | Request) {
245
+ this.push(route);
246
+ }
247
+
214
248
  /**
215
249
  * This replaces the state of the route without triggering an event
216
250
  *
217
251
  * You can use this when using some filters for example a search filter
218
252
  *
253
+ * ## Note
254
+ * This will always set the origin to 'replace'
255
+ * And will clear the scrollY value if you not provide a new one via the `opts`
256
+ * This will disableLoadData by default if you not provide an override via the `opts`
257
+ *
219
258
  * ## Example
220
259
  * ```
221
260
  * import { getRouter } from 'crelte';
@@ -228,11 +267,24 @@ export default class Router {
228
267
  * router.replaceState(route);
229
268
  * ```
230
269
  */
231
- replaceState(route: Route) {
270
+ replace(route: Route | Request, opts: RequestOptions = {}) {
271
+ // cancel previous request
232
272
  this.pageLoader.discard();
233
- this.inner.replaceState(route);
273
+ const req = this.inner.targetToRequest(route, {
274
+ origin: 'replace',
275
+ scrollY: opts.scrollY ?? undefined,
276
+ disableLoadData: opts.disableLoadData ?? true,
277
+ });
278
+ this.inner.replace(req);
234
279
  this.destroyRequest();
235
- this.setNewRoute(route);
280
+ this.setNewRoute(req);
281
+ }
282
+
283
+ /**
284
+ * @deprecated use replace instead
285
+ */
286
+ replaceState(route: Route | Request) {
287
+ this.replace(route);
236
288
  }
237
289
 
238
290
  /**
@@ -259,13 +311,13 @@ export default class Router {
259
311
  /**
260
312
  * Add a listener for the onRoute event
261
313
  *
262
- * This differs from router.route.subscribe in the way that
263
- * it will only trigger if a new render / load will occur
314
+ * This will trigger every time a new route is set
315
+ * and is equivalent to router.route.subscribe(fn)
264
316
  *
265
317
  * @returns a function to remove the listener
266
318
  */
267
319
  onRoute(fn: (route: Route) => void): () => void {
268
- return this._onRouteEv.add(fn);
320
+ return this.route.subscribe(fn);
269
321
  }
270
322
 
271
323
  /**
@@ -297,10 +349,14 @@ export default class Router {
297
349
  ): Promise<ServerInited> {
298
350
  this.inner.initServer();
299
351
 
352
+ this._internal.onNothingLoaded = (_req, ready) => {
353
+ ready();
354
+ };
355
+
300
356
  const prom: Promise<ServerInited> = new Promise(resolve => {
301
357
  this._internal.onLoaded = (success, req, ready) => {
302
358
  const props = ready();
303
- this._internal.onLoaded = () => {};
359
+ this._internal.onLoaded = () => { };
304
360
 
305
361
  resolve({
306
362
  success,
@@ -360,7 +416,11 @@ export default class Router {
360
416
  this._onRequest.trigger(req);
361
417
 
362
418
  // route prepared
363
- this.pageLoader.load(req, { changeHistory });
419
+ if (!req.disableLoadData) {
420
+ this.pageLoader.load(req, { changeHistory });
421
+ } else {
422
+ this._onNothingLoaded(req, { changeHistory });
423
+ }
364
424
  }
365
425
 
366
426
  private destroyRequest() {
@@ -389,15 +449,28 @@ export default class Router {
389
449
 
390
450
  const route = req.toRoute();
391
451
 
392
- const updateRoute = () => {
452
+ // call the client or server saying we are ready for a new render
453
+ this._internal.onLoaded(resp.success, req, () => {
393
454
  this.setNewRoute(route);
455
+ return resp.data;
456
+ });
457
+ }
394
458
 
395
- this._onRouteEv.trigger(route.clone());
396
- };
459
+ private async _onNothingLoaded(req: Request, more: LoadedMore) {
460
+ // check if the render was cancelled
461
+ if (await req._renderBarrier.ready()) return;
397
462
 
398
- this._internal.onLoaded(resp.success, req, () => {
399
- updateRoute();
400
- return resp.data;
463
+ // when the data is loaded let's update the route of the inner
464
+ // this is will only happen if no other route has been requested
465
+ // in the meantime
466
+ more.changeHistory();
467
+
468
+ const route = req.toRoute();
469
+
470
+ // call the client or server saying there was an update in the route
471
+ // but no new data was loaded so no render should happen
472
+ this._internal.onNothingLoaded(req, () => {
473
+ this.setNewRoute(route);
401
474
  });
402
475
  }
403
476