@sveltejs/kit 1.0.0-next.296 → 1.0.0-next.299

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 { onMount, tick } from 'svelte';
2
2
  import { writable } from 'svelte/store';
3
3
  import { assets, set_paths } from '../paths.js';
4
4
  import Root from '__GENERATED__/root.svelte';
5
- import { routes, fallback } from '__GENERATED__/manifest.js';
5
+ import { components, dictionary, validators } from '__GENERATED__/client-manifest.js';
6
6
  import { init } from './singletons.js';
7
7
 
8
8
  /**
@@ -250,9 +250,108 @@ function initial_fetch(resource, opts) {
250
250
  return fetch(resource, opts);
251
251
  }
252
252
 
253
+ /** @param {string} key */
254
+ function parse_route_id(key) {
255
+ /** @type {string[]} */
256
+ const names = [];
257
+
258
+ /** @type {string[]} */
259
+ const types = [];
260
+
261
+ const pattern =
262
+ key === ''
263
+ ? /^\/$/
264
+ : new RegExp(
265
+ `^${decodeURIComponent(key)
266
+ .split('/')
267
+ .map((segment) => {
268
+ // special case — /[...rest]/ could contain zero segments
269
+ const match = /^\[\.\.\.(\w+)(?:=\w+)?\]$/.exec(segment);
270
+ if (match) {
271
+ names.push(match[1]);
272
+ types.push(match[2]);
273
+ return '(?:/(.*))?';
274
+ }
275
+
276
+ return (
277
+ '/' +
278
+ segment.replace(/\[(\.\.\.)?(\w+)(?:=(\w+))?\]/g, (m, rest, name, type) => {
279
+ names.push(name);
280
+ types.push(type);
281
+ return rest ? '(.*?)' : '([^/]+?)';
282
+ })
283
+ );
284
+ })
285
+ .join('')}/?$`
286
+ );
287
+
288
+ return { pattern, names, types };
289
+ }
290
+
291
+ /**
292
+ * @param {RegExpMatchArray} match
293
+ * @param {string[]} names
294
+ * @param {string[]} types
295
+ * @param {Record<string, import('types').ParamValidator>} validators
296
+ */
297
+ function exec(match, names, types, validators) {
298
+ /** @type {Record<string, string>} */
299
+ const params = {};
300
+
301
+ for (let i = 0; i < names.length; i += 1) {
302
+ const name = names[i];
303
+ const type = types[i];
304
+ const value = match[i + 1] || '';
305
+
306
+ if (type) {
307
+ const validator = validators[type];
308
+ if (!validator) throw new Error(`Missing "${type}" param validator`); // TODO do this ahead of time?
309
+
310
+ if (!validator(value)) return;
311
+ }
312
+
313
+ params[name] = value;
314
+ }
315
+
316
+ return params;
317
+ }
318
+
319
+ /**
320
+ * @param {import('types').CSRComponentLoader[]} components
321
+ * @param {Record<string, [number[], number[], 1?]>} dictionary
322
+ * @param {Record<string, (param: string) => boolean>} validators
323
+ * @returns {import('types').CSRRoute[]}
324
+ */
325
+ function parse(components, dictionary, validators) {
326
+ const routes = Object.entries(dictionary).map(([id, [a, b, has_shadow]]) => {
327
+ const { pattern, names, types } = parse_route_id(id);
328
+
329
+ return {
330
+ id,
331
+ /** @param {string} path */
332
+ exec: (path) => {
333
+ const match = pattern.exec(path);
334
+ if (match) return exec(match, names, types, validators);
335
+ },
336
+ a: a.map((n) => components[n]),
337
+ b: b.map((n) => components[n]),
338
+ has_shadow: !!has_shadow
339
+ };
340
+ });
341
+
342
+ return routes;
343
+ }
344
+
253
345
  const SCROLL_KEY = 'sveltekit:scroll';
254
346
  const INDEX_KEY = 'sveltekit:index';
255
347
 
348
+ const routes = parse(components, dictionary, validators);
349
+
350
+ // we import the root layout/error components eagerly, so that
351
+ // connectivity errors after initialisation don't nuke the app
352
+ const default_layout = components[0]();
353
+ const default_error = components[1]();
354
+
256
355
  // We track the scroll position associated with each history entry in sessionStorage,
257
356
  // rather than on history.state itself, because when navigation is driven by
258
357
  // popstate it's too late to update the scroll position associated with the
@@ -339,8 +438,7 @@ function create_client({ target, session, base, trailing_slash }) {
339
438
  if (!ready) return;
340
439
  session_id += 1;
341
440
 
342
- const intent = get_navigation_intent(new URL(location.href));
343
- update(intent, [], true);
441
+ update(new URL(location.href), [], true);
344
442
  });
345
443
  ready = true;
346
444
 
@@ -405,29 +503,31 @@ function create_client({ target, session, base, trailing_slash }) {
405
503
 
406
504
  /** @param {URL} url */
407
505
  async function prefetch(url) {
408
- if (!owns(url)) {
506
+ const intent = get_navigation_intent(url);
507
+
508
+ if (!intent) {
409
509
  throw new Error('Attempted to prefetch a URL that does not belong to this app');
410
510
  }
411
511
 
412
- const intent = get_navigation_intent(url);
413
-
414
- load_cache.promise = get_navigation_result(intent, false);
512
+ load_cache.promise = load_route(intent, false);
415
513
  load_cache.id = intent.id;
416
514
 
417
515
  return load_cache.promise;
418
516
  }
419
517
 
420
518
  /**
421
- * @param {import('./types').NavigationIntent} intent
519
+ * @param {URL} url
422
520
  * @param {string[]} redirect_chain
423
521
  * @param {boolean} no_cache
424
522
  * @param {{hash?: string, scroll: { x: number, y: number } | null, keepfocus: boolean, details: { replaceState: boolean, state: any } | null}} [opts]
425
523
  */
426
- async function update(intent, redirect_chain, no_cache, opts) {
524
+ async function update(url, redirect_chain, no_cache, opts) {
525
+ const intent = get_navigation_intent(url);
526
+
427
527
  const current_token = (token = {});
428
- let navigation_result = await get_navigation_result(intent, no_cache);
528
+ let navigation_result = intent && (await load_route(intent, no_cache));
429
529
 
430
- if (!navigation_result && intent.url.pathname === location.pathname) {
530
+ if (!navigation_result && url.pathname === location.pathname) {
431
531
  // this could happen in SPA fallback mode if the user navigated to
432
532
  // `/non-existent-page`. if we fall back to reloading the page, it
433
533
  // will create an infinite loop. so whereas we normally handle
@@ -435,13 +535,14 @@ function create_client({ target, session, base, trailing_slash }) {
435
535
  // we render a client-side error page instead
436
536
  navigation_result = await load_root_error_page({
437
537
  status: 404,
438
- error: new Error(`Not found: ${intent.url.pathname}`),
439
- url: intent.url
538
+ error: new Error(`Not found: ${url.pathname}`),
539
+ url,
540
+ routeId: null
440
541
  });
441
542
  }
442
543
 
443
544
  if (!navigation_result) {
444
- await native_navigation(intent.url);
545
+ await native_navigation(url);
445
546
  return; // unnecessary, but TypeScript prefers it this way
446
547
  }
447
548
 
@@ -451,17 +552,18 @@ function create_client({ target, session, base, trailing_slash }) {
451
552
  invalidated.clear();
452
553
 
453
554
  if (navigation_result.redirect) {
454
- if (redirect_chain.length > 10 || redirect_chain.includes(intent.url.pathname)) {
555
+ if (redirect_chain.length > 10 || redirect_chain.includes(url.pathname)) {
455
556
  navigation_result = await load_root_error_page({
456
557
  status: 500,
457
558
  error: new Error('Redirect loop'),
458
- url: intent.url
559
+ url,
560
+ routeId: null
459
561
  });
460
562
  } else {
461
563
  if (router_enabled) {
462
- goto(new URL(navigation_result.redirect, intent.url).href, {}, [
564
+ goto(new URL(navigation_result.redirect, url).href, {}, [
463
565
  ...redirect_chain,
464
- intent.url.pathname
566
+ url.pathname
465
567
  ]);
466
568
  } else {
467
569
  await native_navigation(new URL(navigation_result.redirect, location.href));
@@ -472,7 +574,7 @@ function create_client({ target, session, base, trailing_slash }) {
472
574
  } else if (navigation_result.props?.page?.status >= 400) {
473
575
  const updated = await stores.updated.check();
474
576
  if (updated) {
475
- await native_navigation(intent.url);
577
+ await native_navigation(url);
476
578
  }
477
579
  }
478
580
 
@@ -482,7 +584,7 @@ function create_client({ target, session, base, trailing_slash }) {
482
584
  const { details } = opts;
483
585
  const change = details.replaceState ? 0 : 1;
484
586
  details.state[INDEX_KEY] = current_history_index += change;
485
- history[details.replaceState ? 'replaceState' : 'pushState'](details.state, '', intent.url);
587
+ history[details.replaceState ? 'replaceState' : 'pushState'](details.state, '', url);
486
588
  }
487
589
 
488
590
  if (started) {
@@ -522,7 +624,7 @@ function create_client({ target, session, base, trailing_slash }) {
522
624
  await tick();
523
625
 
524
626
  if (autoscroll) {
525
- const deep_linked = intent.url.hash && document.getElementById(intent.url.hash.slice(1));
627
+ const deep_linked = url.hash && document.getElementById(url.hash.slice(1));
526
628
  if (scroll) {
527
629
  scrollTo(scroll.x, scroll.y);
528
630
  } else if (deep_linked) {
@@ -575,36 +677,6 @@ function create_client({ target, session, base, trailing_slash }) {
575
677
  }
576
678
  }
577
679
 
578
- /**
579
- * @param {import('./types').NavigationIntent} intent
580
- * @param {boolean} no_cache
581
- */
582
- async function get_navigation_result(intent, no_cache) {
583
- if (load_cache.id === intent.id && load_cache.promise) {
584
- return load_cache.promise;
585
- }
586
-
587
- for (let i = 0; i < intent.routes.length; i += 1) {
588
- const route = intent.routes[i];
589
-
590
- // load code for subsequent routes immediately, if they are as
591
- // likely to match the current path/query as the current one
592
- let j = i + 1;
593
- while (j < intent.routes.length) {
594
- const next = intent.routes[j];
595
- if (next[0].toString() === route[0].toString()) {
596
- next[1].forEach((loader) => loader());
597
- j += 1;
598
- } else {
599
- break;
600
- }
601
- }
602
-
603
- const result = await load_route(route, intent, no_cache);
604
- if (result) return result;
605
- }
606
- }
607
-
608
680
  /**
609
681
  *
610
682
  * @param {{
@@ -614,9 +686,18 @@ function create_client({ target, session, base, trailing_slash }) {
614
686
  * branch: Array<import('./types').BranchNode | undefined>;
615
687
  * status: number;
616
688
  * error?: Error;
689
+ * routeId: string | null;
617
690
  * }} opts
618
691
  */
619
- async function get_navigation_result_from_branch({ url, params, stuff, branch, status, error }) {
692
+ async function get_navigation_result_from_branch({
693
+ url,
694
+ params,
695
+ stuff,
696
+ branch,
697
+ status,
698
+ error,
699
+ routeId
700
+ }) {
620
701
  const filtered = /** @type {import('./types').BranchNode[] } */ (branch.filter(Boolean));
621
702
  const redirect = filtered.find((f) => f.loaded?.redirect);
622
703
 
@@ -640,7 +721,7 @@ function create_client({ target, session, base, trailing_slash }) {
640
721
  }
641
722
 
642
723
  if (!current.url || url.href !== current.url.href) {
643
- result.props.page = { url, params, status, error, stuff };
724
+ result.props.page = { error, params, routeId, status, stuff, url };
644
725
 
645
726
  // TODO remove this for 1.0
646
727
  /**
@@ -699,9 +780,10 @@ function create_client({ target, session, base, trailing_slash }) {
699
780
  * params: Record<string, string>;
700
781
  * stuff: Record<string, any>;
701
782
  * props?: Record<string, any>;
783
+ * routeId: string | null;
702
784
  * }} options
703
785
  */
704
- async function load_node({ status, error, module, url, params, stuff, props }) {
786
+ async function load_node({ status, error, module, url, params, stuff, props, routeId }) {
705
787
  /** @type {import('./types').BranchNode} */
706
788
  const node = {
707
789
  module,
@@ -738,6 +820,7 @@ function create_client({ target, session, base, trailing_slash }) {
738
820
  if (module.load) {
739
821
  /** @type {import('types').LoadInput | import('types').ErrorLoadInput} */
740
822
  const load_input = {
823
+ routeId,
741
824
  params: uses_params,
742
825
  props: props || {},
743
826
  get url() {
@@ -791,21 +874,20 @@ function create_client({ target, session, base, trailing_slash }) {
791
874
  }
792
875
 
793
876
  /**
794
- * @param {import('types').CSRRoute} route
795
877
  * @param {import('./types').NavigationIntent} intent
796
878
  * @param {boolean} no_cache
797
879
  */
798
- async function load_route(route, { id, url, path }, no_cache) {
880
+ async function load_route({ id, url, params, route }, no_cache) {
881
+ if (load_cache.id === id && load_cache.promise) {
882
+ return load_cache.promise;
883
+ }
884
+
799
885
  if (!no_cache) {
800
886
  const cached = cache.get(id);
801
887
  if (cached) return cached;
802
888
  }
803
889
 
804
- const [pattern, a, b, get_params, shadow_key] = route;
805
- const params = get_params
806
- ? // the pattern is for the route which we've already matched to this path
807
- get_params(/** @type {RegExpExecArray} */ (pattern.exec(path)))
808
- : {};
890
+ const { a, b, has_shadow } = route;
809
891
 
810
892
  const changed = current.url && {
811
893
  url: id !== current.url.pathname + current.url.search,
@@ -852,14 +934,14 @@ function create_client({ target, session, base, trailing_slash }) {
852
934
  /** @type {Record<string, any>} */
853
935
  let props = {};
854
936
 
855
- const is_shadow_page = shadow_key !== undefined && i === a.length - 1;
937
+ const is_shadow_page = has_shadow && i === a.length - 1;
856
938
 
857
939
  if (is_shadow_page) {
858
940
  const res = await fetch(
859
941
  `${url.pathname}${url.pathname.endsWith('/') ? '' : '/'}__data.json${url.search}`,
860
942
  {
861
943
  headers: {
862
- 'x-sveltekit-load': /** @type {string} */ (shadow_key)
944
+ 'x-sveltekit-load': 'true'
863
945
  }
864
946
  }
865
947
  );
@@ -875,11 +957,7 @@ function create_client({ target, session, base, trailing_slash }) {
875
957
  };
876
958
  }
877
959
 
878
- if (res.status === 204) {
879
- // fallthrough
880
- return;
881
- }
882
- props = await res.json();
960
+ props = res.status === 204 ? {} : await res.json();
883
961
  } else {
884
962
  status = res.status;
885
963
  error = new Error('Failed to load data');
@@ -892,7 +970,8 @@ function create_client({ target, session, base, trailing_slash }) {
892
970
  url,
893
971
  params,
894
972
  props,
895
- stuff
973
+ stuff,
974
+ routeId: route.id
896
975
  });
897
976
  }
898
977
 
@@ -902,9 +981,14 @@ function create_client({ target, session, base, trailing_slash }) {
902
981
  }
903
982
 
904
983
  if (node.loaded) {
984
+ // TODO remove for 1.0
985
+ // @ts-expect-error
905
986
  if (node.loaded.fallthrough) {
906
- return;
987
+ throw new Error(
988
+ 'fallthrough is no longer supported. Use validators instead: https://kit.svelte.dev/docs/routing#advanced-routing-validation'
989
+ );
907
990
  }
991
+
908
992
  if (node.loaded.error) {
909
993
  status = node.loaded.status;
910
994
  error = node.loaded.error;
@@ -950,7 +1034,8 @@ function create_client({ target, session, base, trailing_slash }) {
950
1034
  module: await b[i](),
951
1035
  url,
952
1036
  params,
953
- stuff: node_loaded.stuff
1037
+ stuff: node_loaded.stuff,
1038
+ routeId: route.id
954
1039
  });
955
1040
 
956
1041
  if (error_loaded?.loaded?.error) {
@@ -975,7 +1060,8 @@ function create_client({ target, session, base, trailing_slash }) {
975
1060
  return await load_root_error_page({
976
1061
  status,
977
1062
  error,
978
- url
1063
+ url,
1064
+ routeId: route.id
979
1065
  });
980
1066
  } else {
981
1067
  if (node?.loaded?.stuff) {
@@ -995,7 +1081,8 @@ function create_client({ target, session, base, trailing_slash }) {
995
1081
  stuff,
996
1082
  branch,
997
1083
  status,
998
- error
1084
+ error,
1085
+ routeId: route.id
999
1086
  });
1000
1087
  }
1001
1088
 
@@ -1004,26 +1091,29 @@ function create_client({ target, session, base, trailing_slash }) {
1004
1091
  * status: number;
1005
1092
  * error: Error;
1006
1093
  * url: URL;
1094
+ * routeId: string | null
1007
1095
  * }} opts
1008
1096
  */
1009
- async function load_root_error_page({ status, error, url }) {
1097
+ async function load_root_error_page({ status, error, url, routeId }) {
1010
1098
  /** @type {Record<string, string>} */
1011
1099
  const params = {}; // error page does not have params
1012
1100
 
1013
1101
  const root_layout = await load_node({
1014
- module: await fallback[0],
1102
+ module: await default_layout,
1015
1103
  url,
1016
1104
  params,
1017
- stuff: {}
1105
+ stuff: {},
1106
+ routeId
1018
1107
  });
1019
1108
 
1020
1109
  const root_error = await load_node({
1021
1110
  status,
1022
1111
  error,
1023
- module: await fallback[1],
1112
+ module: await default_error,
1024
1113
  url,
1025
1114
  params,
1026
- stuff: (root_layout && root_layout.loaded && root_layout.loaded.stuff) || {}
1115
+ stuff: (root_layout && root_layout.loaded && root_layout.loaded.stuff) || {},
1116
+ routeId
1027
1117
  });
1028
1118
 
1029
1119
  return await get_navigation_result_from_branch({
@@ -1035,28 +1125,32 @@ function create_client({ target, session, base, trailing_slash }) {
1035
1125
  },
1036
1126
  branch: [root_layout, root_error],
1037
1127
  status,
1038
- error
1128
+ error,
1129
+ routeId
1039
1130
  });
1040
1131
  }
1041
1132
 
1042
- /** @param {URL} url */
1043
- function owns(url) {
1044
- return url.origin === location.origin && url.pathname.startsWith(base);
1045
- }
1046
-
1047
1133
  /** @param {URL} url */
1048
1134
  function get_navigation_intent(url) {
1135
+ if (url.origin !== location.origin || !url.pathname.startsWith(base)) return;
1136
+
1049
1137
  const path = decodeURI(url.pathname.slice(base.length) || '/');
1050
1138
 
1051
- /** @type {import('./types').NavigationIntent} */
1052
- const intent = {
1053
- id: url.pathname + url.search,
1054
- routes: routes.filter(([pattern]) => pattern.test(path)),
1055
- url,
1056
- path
1057
- };
1139
+ for (const route of routes) {
1140
+ const params = route.exec(path);
1058
1141
 
1059
- return intent;
1142
+ if (params) {
1143
+ /** @type {import('./types').NavigationIntent} */
1144
+ const intent = {
1145
+ id: url.pathname + url.search,
1146
+ route,
1147
+ params,
1148
+ url
1149
+ };
1150
+
1151
+ return intent;
1152
+ }
1153
+ }
1060
1154
  }
1061
1155
 
1062
1156
  /**
@@ -1090,14 +1184,8 @@ function create_client({ target, session, base, trailing_slash }) {
1090
1184
  return;
1091
1185
  }
1092
1186
 
1093
- if (!owns(url)) {
1094
- await native_navigation(url);
1095
- }
1096
-
1097
1187
  const pathname = normalize_path(url.pathname, trailing_slash);
1098
- url = new URL(url.origin + pathname + url.search + url.hash);
1099
-
1100
- const intent = get_navigation_intent(url);
1188
+ const normalized = new URL(url.origin + pathname + url.search + url.hash);
1101
1189
 
1102
1190
  update_scroll_positions(current_history_index);
1103
1191
 
@@ -1110,11 +1198,11 @@ function create_client({ target, session, base, trailing_slash }) {
1110
1198
  if (started) {
1111
1199
  stores.navigating.set({
1112
1200
  from: current.url,
1113
- to: intent.url
1201
+ to: normalized
1114
1202
  });
1115
1203
  }
1116
1204
 
1117
- await update(intent, redirect_chain, false, {
1205
+ await update(normalized, redirect_chain, false, {
1118
1206
  scroll,
1119
1207
  keepfocus,
1120
1208
  details
@@ -1126,7 +1214,7 @@ function create_client({ target, session, base, trailing_slash }) {
1126
1214
  if (navigating_token !== current_navigating_token) return;
1127
1215
 
1128
1216
  if (!navigating) {
1129
- const navigation = { from, to: url };
1217
+ const navigation = { from, to: normalized };
1130
1218
  callbacks.after_navigate.forEach((fn) => fn(navigation));
1131
1219
 
1132
1220
  stores.navigating.set(null);
@@ -1186,8 +1274,7 @@ function create_client({ target, session, base, trailing_slash }) {
1186
1274
 
1187
1275
  if (!invalidating) {
1188
1276
  invalidating = Promise.resolve().then(async () => {
1189
- const intent = get_navigation_intent(new URL(location.href));
1190
- await update(intent, [], true);
1277
+ await update(new URL(location.href), [], true);
1191
1278
 
1192
1279
  invalidating = null;
1193
1280
  });
@@ -1204,10 +1291,10 @@ function create_client({ target, session, base, trailing_slash }) {
1204
1291
  // TODO rethink this API
1205
1292
  prefetch_routes: async (pathnames) => {
1206
1293
  const matching = pathnames
1207
- ? routes.filter((route) => pathnames.some((pathname) => route[0].test(pathname)))
1294
+ ? routes.filter((route) => pathnames.some((pathname) => route.exec(pathname)))
1208
1295
  : routes;
1209
1296
 
1210
- const promises = matching.map((r) => Promise.all(r[1].map((load) => load())));
1297
+ const promises = matching.map((r) => Promise.all(r.a.map((load) => load())));
1211
1298
 
1212
1299
  await Promise.all(promises);
1213
1300
  },
@@ -1385,7 +1472,7 @@ function create_client({ target, session, base, trailing_slash }) {
1385
1472
  });
1386
1473
  },
1387
1474
 
1388
- _hydrate: async ({ status, error, nodes, params }) => {
1475
+ _hydrate: async ({ status, error, nodes, params, routeId }) => {
1389
1476
  const url = new URL(location.href);
1390
1477
 
1391
1478
  /** @type {Array<import('./types').BranchNode | undefined>} */
@@ -1419,7 +1506,8 @@ function create_client({ target, session, base, trailing_slash }) {
1419
1506
  stuff,
1420
1507
  status: is_leaf ? status : undefined,
1421
1508
  error: is_leaf ? error : undefined,
1422
- props
1509
+ props,
1510
+ routeId
1423
1511
  });
1424
1512
 
1425
1513
  if (props) {
@@ -1435,7 +1523,8 @@ function create_client({ target, session, base, trailing_slash }) {
1435
1523
  error_args = {
1436
1524
  status: node.loaded.status,
1437
1525
  error: node.loaded.error,
1438
- url
1526
+ url,
1527
+ routeId
1439
1528
  };
1440
1529
  } else if (node.loaded.stuff) {
1441
1530
  stuff = {
@@ -1454,7 +1543,8 @@ function create_client({ target, session, base, trailing_slash }) {
1454
1543
  stuff,
1455
1544
  branch,
1456
1545
  status,
1457
- error
1546
+ error,
1547
+ routeId
1458
1548
  });
1459
1549
  } catch (e) {
1460
1550
  if (error) throw e;
@@ -1462,7 +1552,8 @@ function create_client({ target, session, base, trailing_slash }) {
1462
1552
  result = await load_root_error_page({
1463
1553
  status: 500,
1464
1554
  error: coalesce_to_error(e),
1465
- url
1555
+ url,
1556
+ routeId
1466
1557
  });
1467
1558
  }
1468
1559
 
@@ -1493,6 +1584,7 @@ function create_client({ target, session, base, trailing_slash }) {
1493
1584
  * error: Error;
1494
1585
  * nodes: Array<Promise<import('types').CSRComponent>>;
1495
1586
  * params: Record<string, string>;
1587
+ * routeId: string | null;
1496
1588
  * };
1497
1589
  * }} opts
1498
1590
  */