@mantou/gem 2.2.15 → 2.3.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.
Files changed (52) hide show
  1. package/dist/gem.js +7 -7
  2. package/dist/gem.mjs +7 -7
  3. package/elements/base/modal-factory.d.ts +2 -1
  4. package/elements/base/modal-factory.d.ts.map +1 -1
  5. package/elements/base/route.d.ts +8 -3
  6. package/elements/base/route.d.ts.map +1 -1
  7. package/elements/base/route.js +84 -44
  8. package/elements/base/route.js.map +1 -1
  9. package/helper/theme.d.ts +8 -4
  10. package/helper/theme.d.ts.map +1 -1
  11. package/helper/theme.js +12 -9
  12. package/helper/theme.js.map +1 -1
  13. package/lib/decorators.d.ts.map +1 -1
  14. package/lib/decorators.js +27 -43
  15. package/lib/decorators.js.map +1 -1
  16. package/lib/history.d.ts.map +1 -1
  17. package/lib/history.js +19 -37
  18. package/lib/history.js.map +1 -1
  19. package/lib/reactive.d.ts +6 -5
  20. package/lib/reactive.d.ts.map +1 -1
  21. package/lib/reactive.js +85 -112
  22. package/lib/reactive.js.map +1 -1
  23. package/lib/server.d.ts +12 -0
  24. package/lib/server.d.ts.map +1 -1
  25. package/lib/server.js +2 -0
  26. package/lib/server.js.map +1 -1
  27. package/lib/store.d.ts +0 -6
  28. package/lib/store.d.ts.map +1 -1
  29. package/lib/store.js +5 -17
  30. package/lib/store.js.map +1 -1
  31. package/lib/version.d.ts +1 -1
  32. package/lib/version.d.ts.map +1 -1
  33. package/lib/version.js +1 -1
  34. package/lib/version.js.map +1 -1
  35. package/package.json +3 -4
  36. package/src/elements/base/route.ts +84 -49
  37. package/src/helper/theme.ts +16 -9
  38. package/src/lib/decorators.ts +34 -48
  39. package/src/lib/history.ts +19 -32
  40. package/src/lib/reactive.ts +89 -120
  41. package/src/lib/server.ts +14 -0
  42. package/src/lib/store.ts +3 -19
  43. package/src/lib/version.ts +1 -1
  44. package/src/test/elements/gesture.test.ts +13 -5
  45. package/src/test/elements/route.test.ts +102 -0
  46. package/src/test/elements/title.test.ts +7 -0
  47. package/src/test/gem-element/advance.test.ts +19 -0
  48. package/src/test/gem-element/basic.test.ts +13 -0
  49. package/src/test/history.test.ts +19 -0
  50. package/src/test/theme.test.ts +8 -1
  51. package/src/test/utils.test.ts +6 -0
  52. package/src/test/utils.ts +56 -0
@@ -1,7 +1,7 @@
1
1
  // biome-ignore-all lint/plugin/no_this_effect: 兼容生命周期
2
2
 
3
3
  import type * as Lit from './lit-html';
4
- import { isServer } from './server';
4
+ import { isServer, ssrHooks } from './server';
5
5
  import type { Store } from './store';
6
6
  import { connect } from './store';
7
7
  import {
@@ -27,21 +27,9 @@ export function setEngine(h: typeof Lit.html, r: typeof Lit.render) {
27
27
  render = r;
28
28
  }
29
29
 
30
- let hydrationEngine = (_value: unknown, _container: HTMLElement | DocumentFragment): Lit.RootPart | undefined => {
31
- return;
32
- };
33
-
34
- // 在元素首次渲染前恢复 SSR 的状态
35
- let restoreState: ((ele: GemElement) => void) | undefined;
36
-
37
- export function setHydrationEngine(engine: typeof hydrationEngine, restore?: typeof restoreState) {
38
- hydrationEngine = engine;
39
- restoreState = restore;
40
- }
41
-
42
- /** 渲染元素内容,SSR 时由水合引擎接管 */
30
+ /** 渲染元素内容,SSR 时由 `ssrHooks.render` 接管 */
43
31
  export const _renderRoot = (value: unknown, container: HTMLElement | DocumentFragment) =>
44
- hydrationEngine(value, container) || render(value, container);
32
+ ssrHooks.render?.(value, container) || render(value, container);
45
33
 
46
34
  const { assign, defineProperty } = Object;
47
35
 
@@ -59,14 +47,15 @@ export const UpdateToken = Symbol.for('gem@update');
59
47
  const BoundaryCSSState = 'gem-style-boundary';
60
48
  const ElementCSSState = 'gem-element';
61
49
 
62
- /**@internal */
50
+ /** 渲染出错时在元素上派发,`@fallback` 处理时取消,SSR 时 `@server` 方法出错也会派发 */
63
51
  export const _RenderErrorEvent = 'gem@render-error';
64
52
 
65
53
  // fix modal-factory type error
66
54
  const updateTokenAlias = UpdateToken;
67
55
 
68
- const rootStyleSheetInfo = new WeakMap<Document | ShadowRoot, Map<CSSStyleSheet, number>>();
69
- const rootUpdateFnMap = new WeakMap<Map<CSSStyleSheet, number>, () => void>();
56
+ // 渲染根中 light DOM 元素使用的样式表和使用次数,以及更新渲染根样式表的函数
57
+ type RootSheets = { counts: Map<CSSStyleSheet, number>; update: () => void };
58
+ const rootSheetsMap = new WeakMap<Document | ShadowRoot, RootSheets>();
70
59
 
71
60
  class GemCSSStyleSheet extends CSSStyleSheet {
72
61
  #ele?: HTMLStyleElement;
@@ -96,45 +85,37 @@ class GemCSSSheet {
96
85
  this.#content = v;
97
86
  }
98
87
 
99
- // 不需要 GC
100
- #record = new Map<any, GemCSSStyleSheet>();
101
- #used = new Map<GemCSSStyleSheet, string>();
88
+ // 对同一类 dom 只使用同一个样式表,key 是元素类或者 `this`,不需要 GC
89
+ #sheets = new Map<any, { sheet: GemCSSStyleSheet; scope: string }>();
90
+
91
+ #apply({ sheet, scope }: { sheet: GemCSSStyleSheet; scope: string }) {
92
+ sheet.replaceSync(scope ? `${scope}{ ${this.#content} }` : this.#content);
93
+ }
94
+
102
95
  getStyle(host?: HTMLElement, isEleTheme?: boolean) {
103
96
  const isLight = host && !(host as GemElement).internals?.shadowRoot;
104
-
105
- // 对同一类 dom 只使用同一个样式表
106
97
  const key = isLight ? host.constructor : this;
107
- if (!this.#record.has(key)) {
108
- const sheet = new GemCSSStyleSheet({ media: this.#media });
109
- this.#record.set(key, sheet);
98
+ let item = this.#sheets.get(key);
99
+ if (!item) {
100
+ // light dom 嵌套时需要选择子元素
101
+ // `> *` 实际上是多范围?是否存在性能问题
102
+ // 不能使用 @layer,两个 @layer 不能覆盖,只有顺序起作用
103
+ // 所以外部不能通过元素名称选择器来覆盖样式,除非样式在之前插入(会自动反转应用到尾部, see `adoptRootStyleSheets`)
104
+ const scope = !isLight
105
+ ? ''
106
+ : isEleTheme
107
+ ? '@scope'
108
+ : `@scope (${host.tagName}) to (:state(${BoundaryCSSState}) > *)`;
109
+ item = { sheet: new GemCSSStyleSheet({ media: this.#media }), scope };
110
+ this.#sheets.set(key, item);
111
+ this.#apply(item);
110
112
  }
111
-
112
- const sheet = this.#record.get(key)!;
113
-
114
- // 只执行一次
115
- if (!this.#used.has(sheet)) {
116
- let style = this.#content;
117
- let scope = '';
118
- if (isLight) {
119
- // light dom 嵌套时需要选择子元素
120
- // `> *` 实际上是多范围?是否存在性能问题
121
- scope = isEleTheme ? '@scope' : `@scope (${host.tagName}) to (:state(${BoundaryCSSState}) > *)`;
122
- // 不能使用 @layer,两个 @layer 不能覆盖,只有顺序起作用
123
- // 所以外部不能通过元素名称选择器来覆盖样式,除非样式在之前插入(会自动反转应用到尾部, see `appleCSSStyleSheet`)
124
- style = `${scope}{ ${style} }`;
125
- }
126
- sheet.replaceSync(style);
127
- this.#used.set(sheet, scope);
128
- }
129
-
130
- return sheet;
113
+ return item.sheet;
131
114
  }
132
115
 
133
116
  // 一般用于主题更新
134
117
  updateStyle() {
135
- this.#used.forEach((scope, sheet) => {
136
- sheet.replaceSync(scope ? `${scope}{${this.#content}}` : this.#content);
137
- });
118
+ this.#sheets.forEach((item) => this.#apply(item));
138
119
  }
139
120
  }
140
121
 
@@ -182,40 +163,38 @@ export function css<T extends Record<string, string>>(
182
163
  return sheet as Sheet<T>;
183
164
  }
184
165
 
185
- const updateStyleSheets = (map: Map<CSSStyleSheet, number>, sheets: CSSStyleSheet[], value: number) => {
166
+ const updateRootSheets = ({ counts, update }: RootSheets, sheets: CSSStyleSheet[], value: number) => {
186
167
  let needUpdate = false;
187
168
  sheets.forEach((e) => {
188
- const count = map.get(e) || 0;
169
+ const count = counts.get(e) || 0;
189
170
  needUpdate ||= (value === 1 && count === 0) || (value === -1 && count === 1);
190
171
  // count 为 0 时不立即删除,以便更新时识别是否外部样式
191
172
  // 但是只要挂载 document 的样式表就不会被 GC 了, 是否要在闲时 GC?
192
- map.set(e, count + value);
173
+ counts.set(e, count + value);
193
174
  });
194
- if (needUpdate) {
195
- // 避免重复更新,例如 list 元素增删,全 light dom 时 document 更新
196
- // 在 SSR 中应该区别 effect 强制执行
197
- addMicrotask(rootUpdateFnMap.get(map)!);
198
- }
175
+ // 避免重复更新,例如 list 元素增删,全 light dom 时 document 更新
176
+ // 在 SSR 中应该区别 effect 强制执行
177
+ if (needUpdate) addMicrotask(update);
199
178
  };
200
179
 
201
- const appleCSSStyleSheet = (ele: HTMLElement, sheets: CSSStyleSheet[]) => {
180
+ /** light DOM 元素的样式表添加到渲染根中,返回移除函数 */
181
+ const adoptRootStyleSheets = (ele: HTMLElement, sheets: CSSStyleSheet[]) => {
202
182
  const root = ele.getRootNode() as ShadowRoot | Document;
203
- if (!rootStyleSheetInfo.has(root)) {
204
- const map = new Map<CSSStyleSheet, number>();
205
- rootStyleSheetInfo.set(root, map);
206
- rootUpdateFnMap.set(map, () => {
183
+ let rootSheets = rootSheetsMap.get(root);
184
+ if (!rootSheets) {
185
+ const counts = new Map<CSSStyleSheet, number>();
186
+ const update = () => {
207
187
  // 先找到外部样式表
208
- const newSheets = (root.adoptedStyleSheets || []).filter((e) => !map.has(e));
209
- map.forEach((count, sheet) => count && newSheets.push(sheet));
188
+ const newSheets = (root.adoptedStyleSheets || []).filter((e) => !counts.has(e));
189
+ counts.forEach((count, sheet) => count && newSheets.push(sheet));
210
190
  // 外层元素的样式要放到最后,以提升优先级,但是只考虑第一次出现样式表的位置
211
191
  root.adoptedStyleSheets = newSheets.reverse();
212
- });
192
+ };
193
+ rootSheets = { counts, update };
194
+ rootSheetsMap.set(root, rootSheets);
213
195
  }
214
-
215
- const map = rootStyleSheetInfo.get(root)!;
216
-
217
- updateStyleSheets(map, sheets, 1);
218
- return () => updateStyleSheets(map, sheets, -1);
196
+ updateRootSheets(rootSheets, sheets, 1);
197
+ return () => updateRootSheets(rootSheets, sheets, -1);
219
198
  };
220
199
 
221
200
  /**必须使用在字段中,否则会读取到错误的实例 */
@@ -232,23 +211,25 @@ type EffectItem<T> = {
232
211
  preCallback?: any;
233
212
  };
234
213
 
235
- function execEffectList(list?: EffectItem<any>[]) {
236
- list?.forEach((effectItem) => {
237
- const { callback, getDep, values, preCallback } = effectItem;
238
- const newValues = getDep?.();
239
- if (!getDep || !values || isArrayChange(values, newValues)) {
240
- execCallback(preCallback);
241
- effectItem.preCallback = callback(newValues, values);
242
- effectItem.values = newValues;
243
- // 更新时也要设置,避免异步的 `#initEffect` 重复执行
244
- effectItem.initialized = true;
245
- }
214
+ function runEffect<T>(item: EffectItem<T>, values: T) {
215
+ execCallback(item.preCallback);
216
+ item.preCallback = item.callback(values, item.values);
217
+ item.values = values;
218
+ // 更新时也要设置,避免异步的 `#initEffect` 重复执行
219
+ item.initialized = true;
220
+ }
221
+
222
+ function execEffectList(list: EffectItem<any>[]) {
223
+ list.forEach((item) => {
224
+ const values = item.getDep?.();
225
+ if (!item.getDep || !item.values || isArrayChange(item.values, values)) runEffect(item, values);
246
226
  });
247
227
  }
248
228
 
249
229
  function clearEffect(list: EffectItem<any>[]) {
250
230
  return list.filter((e) => {
251
231
  execCallback(e.preCallback);
232
+ e.preCallback = undefined;
252
233
  e.initialized = false;
253
234
  e.values = undefined;
254
235
  return e.fixed;
@@ -277,6 +258,8 @@ export type Metadata = Partial<ShadowRootInit> & {
277
258
  >;
278
259
  // 实例化时使用到,DevTools 需要读取
279
260
  observedStores?: Store<any>[];
261
+ /** attribute 对应的字段名,用于 `setAttribute` 等方法设置字段 */
262
+ attrProps?: Record<string, string>;
280
263
  adoptedStyleSheets?: Sheet<unknown>[];
281
264
  // 以下静态字段仅供外部读取,没有实际作用
282
265
  observedProperties?: string[];
@@ -341,8 +324,7 @@ export abstract class GemElement extends HTMLElement {
341
324
  #clearStyle?: (() => void) | undefined;
342
325
 
343
326
  [updateTokenAlias]() {
344
- // 避免 `connectedCallback` 中的 property 赋值造成多余更新
345
- if (this.#isMounted) addMicrotask(this.#update);
327
+ this.#scheduleUpdate();
346
328
  }
347
329
 
348
330
  static {
@@ -357,9 +339,8 @@ export abstract class GemElement extends HTMLElement {
357
339
  }
358
340
  assignState(state, payload);
359
341
  // 避免无限刷新
360
- // 挂载前 set state 不应该触发更新
361
342
  // SSR 中渲染 <gem-route>,其他情况的 SSR 应该也可以执行
362
- if (!ele.#rendering && ele.#isMounted) addMicrotask(ele.#update);
343
+ if (!ele.#rendering) ele.#scheduleUpdate();
363
344
  });
364
345
  ele.#internals.stateList.push(state);
365
346
  return state;
@@ -378,6 +359,10 @@ export abstract class GemElement extends HTMLElement {
378
359
  // 这里认为 rest 不会有自定义字段和 ShadowRootInit 参数冲突
379
360
  this.#renderRoot = !mode ? this : this.shadowRoot || this.attachShadow({ mode, delegatesFocus, ...rest });
380
361
  this.#internals = this.attachInternals() as GemElement['internals'];
362
+ // 定义前添加的 ShadowRoot 对 `ElementInternals` 不可见,例如客户端填充了服务端内容的延时视图元素
363
+ if (this.#renderRoot !== this && !this.#internals.shadowRoot) {
364
+ defineProperty(this.#internals, 'shadowRoot', { value: this.#renderRoot });
365
+ }
381
366
  this.#internals.stateList = [];
382
367
  this.#internals.sheets = [];
383
368
 
@@ -423,10 +408,6 @@ export abstract class GemElement extends HTMLElement {
423
408
  execEffectList(this.#effectList);
424
409
  };
425
410
 
426
- #execMemo = () => {
427
- execEffectList(this.#memoList);
428
- };
429
-
430
411
  /**
431
412
  * - 条件渲染
432
413
  * - 没有 `render` 时显示子内容
@@ -437,7 +418,7 @@ export abstract class GemElement extends HTMLElement {
437
418
  #render = (item?: RenderItem) => {
438
419
  try {
439
420
  this.#rendering = true;
440
- this.#execMemo();
421
+ execEffectList(this.#memoList);
441
422
  const isLight = this.#renderRoot === this;
442
423
  const temp = item ? item.render() : isLight ? undefined : html`<slot></slot>`;
443
424
  if (temp === undefined) return;
@@ -469,15 +450,22 @@ export abstract class GemElement extends HTMLElement {
469
450
  }
470
451
  };
471
452
 
472
- #update = () => {
473
- // SSR 需要同步渲染
453
+ // `@async()` 的元素在空闲时执行,SSR 需要同步执行
454
+ #schedule = (task: () => void) => {
474
455
  if (this.#metadata.noBlocking && !isServer) {
475
- noBlockingTaskList.add(this.#updateCallback);
456
+ noBlockingTaskList.add(task);
476
457
  } else {
477
- this.#updateCallback();
458
+ task();
478
459
  }
479
460
  };
480
461
 
462
+ #update = () => this.#schedule(this.#updateCallback);
463
+
464
+ // 挂载前(例如 `connectedCallback` 中的 property 赋值)不需要更新,挂载时会渲染
465
+ #scheduleUpdate = () => {
466
+ if (this.#isMounted) addMicrotask(this.#update);
467
+ };
468
+
481
469
  #prepareStyle = () => {
482
470
  const { adoptedStyleSheets = [] } = this.#metadata;
483
471
  const { shadowRoot, sheets } = this.#internals;
@@ -487,7 +475,7 @@ export abstract class GemElement extends HTMLElement {
487
475
  shadowRoot.adoptedStyleSheets = clsSheets.concat(sheets);
488
476
  } else {
489
477
  this.prepend(...sheets.map((e) => e.element));
490
- return appleCSSStyleSheet(this, clsSheets);
478
+ return adoptRootStyleSheets(this, clsSheets);
491
479
  }
492
480
  };
493
481
 
@@ -495,14 +483,7 @@ export abstract class GemElement extends HTMLElement {
495
483
  * 元素挂载后执行还未初始化的副作用
496
484
  * */
497
485
  #initEffect = () => {
498
- this.#effectList?.forEach((effectItem) => {
499
- const { callback, getDep, initialized } = effectItem;
500
- if (!initialized) {
501
- effectItem.values = getDep?.();
502
- effectItem.preCallback = callback(effectItem.values);
503
- effectItem.initialized = true;
504
- }
505
- });
486
+ this.#effectList.forEach((item) => !item.initialized && runEffect(item, item.getDep?.()));
506
487
  };
507
488
 
508
489
  /** @lifecycle 将来会彻底移除,现在做兼容实现*/ willMount?(): void | Promise<void>;
@@ -534,6 +515,7 @@ export abstract class GemElement extends HTMLElement {
534
515
  this.#clearStyle = this.#prepareStyle();
535
516
  return;
536
517
  }
518
+ if (ssrHooks.connect?.(this, this.#connectedCallback)) return;
537
519
  this.#notInCons = true;
538
520
  this.#compat();
539
521
 
@@ -546,7 +528,6 @@ export abstract class GemElement extends HTMLElement {
546
528
  if (!mode && this.#renderList.length && !penetrable) this.#internals.states.add(BoundaryCSSState);
547
529
 
548
530
  this.#disconnectStore = observedStores?.map((store) => connect(store, this.#update));
549
- restoreState?.(this);
550
531
  this.#render(this.#renderItem);
551
532
  this.#isMounted = true;
552
533
  this.#clearStyle = this.#prepareStyle();
@@ -562,11 +543,7 @@ export abstract class GemElement extends HTMLElement {
562
543
  * use `@mounted`
563
544
  */
564
545
  connectedCallback(): Unique {
565
- if (this.#metadata.noBlocking && !isServer) {
566
- noBlockingTaskList.add(this.#connectedCallback);
567
- } else {
568
- this.#connectedCallback();
569
- }
546
+ this.#schedule(this.#connectedCallback);
570
547
  return undefined as unknown as Unique;
571
548
  }
572
549
 
@@ -606,17 +583,9 @@ export abstract class GemElement extends HTMLElement {
606
583
  * ```
607
584
  * */
608
585
  effect = <K = any[] | undefined>(callback: EffectCallback<K>, getDep?: K extends any[] ? () => K : undefined) => {
609
- const effectItem: EffectItem<K> = {
610
- callback,
611
- getDep,
612
- initialized: this.#isMounted,
613
- fixed: !this.#notInCons,
614
- };
586
+ const effectItem: EffectItem<K> = { callback, getDep, fixed: !this.#notInCons };
615
587
  // 已挂载时立即执行副作用,未挂载时等挂载后执行
616
- if (this.#isMounted) {
617
- effectItem.values = getDep?.() as K;
618
- effectItem.preCallback = callback(effectItem.values);
619
- }
588
+ if (this.#isMounted) runEffect(effectItem, getDep?.() as K);
620
589
  this.#effectList.push(effectItem);
621
590
  };
622
591
 
@@ -649,7 +618,7 @@ export abstract class GemElement extends HTMLElement {
649
618
  * @helper
650
619
  * async
651
620
  */
652
- update = () => addMicrotask(this.#update);
621
+ update = () => this.#scheduleUpdate();
653
622
  }
654
623
 
655
624
  // 用来禁止 connectCallback 方法被覆盖而破坏 GemElement 的功能
package/src/lib/server.ts CHANGED
@@ -3,6 +3,8 @@
3
3
  // `@mantou/gem-ssr` 在加载 Gem 前提供 `__GEM_SSR__`,返回当前请求的作用域,
4
4
  // 客户端构建时 `unplugin-gem` 把 `globalThis.__GEM_SSR__` 替换成 `undefined`,SSR 代码会被压缩移除
5
5
 
6
+ import type { GemElement } from './reactive';
7
+
6
8
  const { get, set, has, deleteProperty, defineProperty, getOwnPropertyDescriptor, ownKeys } = Reflect;
7
9
 
8
10
  const getRequestScope: (() => WeakMap<object, any> | undefined) | undefined = (globalThis as any).__GEM_SSR__;
@@ -10,6 +12,18 @@ const getRequestScope: (() => WeakMap<object, any> | undefined) | undefined = (g
10
12
  /** 服务端渲染时不执行副作用,同步更新 */
11
13
  export const isServer = !!getRequestScope;
12
14
 
15
+ /** SSR 接入 Gem 的钩子,`@mantou/gem-ssr` 在服务端和客户端分别设置 */
16
+ export const ssrHooks: {
17
+ /** 渲染元素内容,返回 `true` 时跳过渲染:服务端收集模版,客户端水合 */
18
+ render?: (value: unknown, container: HTMLElement | DocumentFragment) => boolean;
19
+ /** 元素挂载前调用,返回 `true` 时推迟挂载,之后调用 `connect` 挂载:客户端恢复状态,等待父模版水合 */
20
+ connect?: (ele: GemElement, connect: () => void) => boolean;
21
+ /** 创建有 key 的 store,返回初始值:客户端恢复服务端的值 */
22
+ createStore?: (key: string) => object | undefined;
23
+ /** 更新有 key 的 store:服务端记录,渲染后输出到页面中 */
24
+ updateStore?: (key: string, store: object) => void;
25
+ } = {};
26
+
13
27
  /**
14
28
  * SSR 时返回当前请求中 `key` 对应的值,第一次访问时使用 `init` 创建,
15
29
  * 不在请求中(包括客户端)时返回 `key` 本身
package/src/lib/store.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { getRequestValue, isolate, isServer } from './server';
1
+ import { getRequestValue, isolate, isServer, ssrHooks } from './server';
2
2
  import { addMicrotask, assignState, createUpdater, type Updater } from './utils';
3
3
 
4
4
  export type Store<T = any> = Updater<T>;
@@ -8,20 +8,6 @@ export const _StoreListenerMap = new WeakMap<Store<any>, Set<() => void>>();
8
8
 
9
9
  const createListeners = () => new Set<() => void>();
10
10
 
11
- // SSR 时当前请求中更新过的有 key 的 store,渲染后输出到页面中
12
- const updatedStores = new Map<string, Store<any>>();
13
-
14
- /** SSR 时当前请求中更新过的有 key 的 store,供 `@mantou/gem-ssr` 使用 */
15
- export const _getUpdatedStores = () => getRequestValue(updatedStores, () => new Map());
16
-
17
- // 客户端读取服务端渲染时 store 的值
18
- let restoreStore: ((key: string) => object | undefined) | undefined;
19
-
20
- /** 设置客户端恢复 store 的方法,供 `@mantou/gem-ssr` 使用 */
21
- export function _setStoreRestorer(restorer: typeof restoreStore) {
22
- restoreStore = restorer;
23
- }
24
-
25
11
  /**
26
12
  * 创建 store,`key` 用来在 SSR 时同步服务端的值,`swc-plugin-gem` 的 `ssr` 选项会为模块顶层的 store 自动添加
27
13
  */
@@ -35,12 +21,10 @@ export function createStore<T = any>(originStore: T, key?: string) {
35
21
  if (!isServer) return;
36
22
  // SSR 时请求中添加的监听器只属于当前请求,其他请求更新 store 时不会触发
37
23
  getRequestValue(listeners, createListeners).forEach(addMicrotask);
38
- const updated = _getUpdatedStores();
39
- // 不在请求中时更新的是全局的值,客户端也一样
40
- if (key && updated !== updatedStores) updated.set(key, store);
24
+ if (key) ssrHooks.updateStore?.(key, store);
41
25
  }),
42
26
  );
43
- if (key && restoreStore) assignState(store, restoreStore(key));
27
+ if (key) assignState(store, ssrHooks.createStore?.(key));
44
28
  _StoreListenerMap.set(store, listeners);
45
29
  return store;
46
30
  }
@@ -1 +1 @@
1
- export const version = '2.2.15';
1
+ export const version = '2.3.0';
@@ -1,6 +1,6 @@
1
1
  import type { EndEventDetail, GemGestureElement, PanEventDetail, SwipeEventDetail } from '../../elements/gesture';
2
2
  import { html } from '../../lib/element';
3
- import { aTimeout, expect, fixture } from '../utils';
3
+ import { aTimeout, createFakeTimers, expect, fixture } from '../utils';
4
4
 
5
5
  import '../../elements/gesture';
6
6
 
@@ -20,6 +20,12 @@ function pointer(type: string, [timeStamp, clientX, clientY = 0]: Sample, pointe
20
20
  return event;
21
21
  }
22
22
 
23
+ const clock = createFakeTimers();
24
+
25
+ afterEach(() => {
26
+ clock.uninstall();
27
+ });
28
+
23
29
  async function setupGesture(touchAction = '') {
24
30
  const el: GemGestureElement = await fixture(html`<gem-gesture touch-action=${touchAction}></gem-gesture>`);
25
31
  const captured = new Set<number>();
@@ -43,9 +49,9 @@ describe('GemGestureElement end', () => {
43
49
  const { el, dispatch } = await setupGesture();
44
50
  const ends: EndEventDetail[] = [];
45
51
  el.addEventListener('end', (evt: CustomEvent<EndEventDetail>) => ends.push(evt.detail));
46
- const pressed = new Promise((resolve) => el.addEventListener('press', resolve, { once: true }));
52
+ clock.install();
47
53
  dispatch('pointerdown', [0, 0]);
48
- await pressed;
54
+ clock.tick(251);
49
55
  const event = pointer(type, [300, 10, 20]);
50
56
  el.dispatchEvent(event);
51
57
  await Promise.resolve();
@@ -68,7 +74,9 @@ describe('GemGestureElement end', () => {
68
74
  el.addEventListener('end', (evt: CustomEvent<EndEventDetail>) => resolve(evt.detail), { once: true });
69
75
  });
70
76
  el.addEventListener('press', () => dispatch('pointerup', [300, 0]), { once: true });
77
+ clock.install();
71
78
  dispatch('pointerdown', [0, 0]);
79
+ clock.tick(251);
72
80
  expect((await ended).pressed).to.equal(true);
73
81
  });
74
82
  });
@@ -330,9 +338,9 @@ describe('GemGestureElement swipe', () => {
330
338
 
331
339
  it('does not swipe after a long press', async () => {
332
340
  const { el, swipes, dispatch } = await setupGesture();
333
- const pressed = new Promise((resolve) => el.addEventListener('press', resolve, { once: true }));
341
+ clock.install();
334
342
  dispatch('pointerdown', [0, 0]);
335
- await pressed;
343
+ clock.tick(251);
336
344
  dispatch('pointermove', [300, 0]);
337
345
  dispatch('pointermove', [340, 60]);
338
346
  dispatch('pointerup', [350, 60]);
@@ -0,0 +1,102 @@
1
+ import type { GemRouteElement } from '../../elements/route';
2
+ import { html } from '../../lib/element';
3
+ import { history } from '../../lib/history';
4
+ import { expect, fixture, nextFrame, waitForPopState } from '../utils';
5
+
6
+ import '../../elements/route';
7
+
8
+ const routes = [{ pattern: '*', content: html`<div style="height: 1000px"></div>` }];
9
+
10
+ // 滚动位置在路由渲染后更新,恢复位置可能晚一个微任务,等待 `routechange` 后已经更新
11
+ const rendered = (route: GemRouteElement) =>
12
+ new Promise((resolve) => route.addEventListener('routechange', resolve, { once: true }));
13
+
14
+ let container: HTMLElement;
15
+
16
+ async function createRoute() {
17
+ container = await fixture(
18
+ html`<div style="height: 100px; overflow: auto"><gem-route .routes=${routes}></gem-route></div>`,
19
+ );
20
+ return container.querySelector<GemRouteElement>('gem-route')!;
21
+ }
22
+
23
+ async function navigate(route: GemRouteElement, action: () => void) {
24
+ const promise = rendered(route);
25
+ action();
26
+ await promise;
27
+ }
28
+
29
+ describe('<gem-route> scrollContainer', () => {
30
+ // 所有路由共享滚动位置,避免之前的路由记录位置
31
+ afterEach(() => container.remove());
32
+
33
+ it('导航时滚动到顶部,后退时恢复位置,同一条历史记录重新渲染时保持位置', async () => {
34
+ const route = await createRoute();
35
+ route.scrollContainer = container;
36
+ await nextFrame();
37
+ await navigate(route, () => history.push({ path: '/scroll-a' }));
38
+ container.scrollTop = 300;
39
+ await navigate(route, () => history.push({ path: '/scroll-b' }));
40
+ expect(container.scrollTop).to.equal(0);
41
+ container.scrollTop = 100;
42
+ await navigate(route, () => (route.key = 1));
43
+ expect(container.scrollTop).to.equal(100);
44
+ await navigate(route, () => waitForPopState(() => history.back()));
45
+ expect(container.scrollTop).to.equal(300);
46
+ });
47
+
48
+ it('前进后退时位置未知则滚动到顶部', async () => {
49
+ const route = await createRoute();
50
+ await navigate(route, () => history.push({ path: '/scroll-c' }));
51
+ await navigate(route, () => history.push({ path: '/scroll-d' }));
52
+ route.scrollContainer = container;
53
+ await nextFrame();
54
+ container.scrollTop = 50;
55
+ await navigate(route, () => waitForPopState(() => history.back()));
56
+ expect(container.scrollTop).to.equal(0);
57
+ });
58
+
59
+ it('前进后退时位置未知则保持文档的滚动位置', async () => {
60
+ // 避免浏览器恢复滚动位置
61
+ window.history.scrollRestoration = 'manual';
62
+ document.body.style.minHeight = '3000px';
63
+ const route = await createRoute();
64
+ await navigate(route, () => history.push({ path: '/scroll-f' }));
65
+ await navigate(route, () => history.push({ path: '/scroll-g' }));
66
+ route.scrollContainer = document.scrollingElement!;
67
+ await nextFrame();
68
+ scrollTo(0, 50);
69
+ await navigate(route, () => waitForPopState(() => history.back()));
70
+ expect(scrollY).to.equal(50);
71
+ scrollTo(0, 0);
72
+ document.body.style.minHeight = '';
73
+ window.history.scrollRestoration = 'auto';
74
+ });
75
+
76
+ it('恢复位置前等待 scrollWait', async () => {
77
+ const route = await createRoute();
78
+ route.scrollContainer = container;
79
+ let resolve = () => {};
80
+ const ready = new Promise<void>((r) => (resolve = r));
81
+ route.scrollWait = () => ready;
82
+ await nextFrame();
83
+ await navigate(route, () => history.push({ path: '/scroll-h' }));
84
+ container.scrollTop = 300;
85
+ await navigate(route, () => history.push({ path: '/scroll-i' }));
86
+ expect(container.scrollTop).to.equal(0);
87
+ await navigate(route, () => waitForPopState(() => history.back()));
88
+ expect(container.scrollTop).to.equal(0);
89
+ resolve();
90
+ await ready;
91
+ expect(container.scrollTop).to.equal(300);
92
+ });
93
+
94
+ it('滚动容器为空时不滚动', async () => {
95
+ const route = await createRoute();
96
+ route.scrollContainer = () => null;
97
+ await nextFrame();
98
+ container.scrollTop = 80;
99
+ await navigate(route, () => history.push({ path: '/scroll-e' }));
100
+ expect(container.scrollTop).to.equal(80);
101
+ });
102
+ });
@@ -21,5 +21,12 @@ describe('GemTitleElement', () => {
21
21
  await nextFrame();
22
22
  expect(document.title).to.equal('prefix-Page');
23
23
  expect(ele.shadowRoot!.textContent).to.equal('Page');
24
+
25
+ // 只改变 hash 时保持标题,不重复添加前缀
26
+ location.hash = '#a';
27
+ await nextFrame();
28
+ await nextFrame();
29
+ expect(document.title).to.equal('prefix-Page');
30
+ expect(ele.shadowRoot!.textContent).to.equal('Page');
24
31
  });
25
32
  });