haori 0.6.1 → 0.6.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.
@@ -8,6 +8,253 @@ import Queue from './queue';
8
8
  import Log from './log';
9
9
  import Expression from './expression';
10
10
  import Env from './env';
11
+ import Dev from './dev';
12
+ /**
13
+ * 開発時の属性・テキスト評価回数を集計するレジストリです。
14
+ */
15
+ class EvaluationProfileRegistry {
16
+ /**
17
+ * 集計状態を初期化します。
18
+ */
19
+ static reset() {
20
+ EvaluationProfileRegistry.ELEMENT_STORES.clear();
21
+ EvaluationProfileRegistry.ensureGlobalAccess();
22
+ }
23
+ /**
24
+ * 現在の集計結果スナップショットを返します。
25
+ *
26
+ * @returns エレメントごとの集計結果
27
+ */
28
+ static snapshot() {
29
+ EvaluationProfileRegistry.ensureGlobalAccess();
30
+ return [...EvaluationProfileRegistry.ELEMENT_STORES.entries()]
31
+ .map(([elementId, store]) => ({
32
+ elementId,
33
+ tagName: store.tagName,
34
+ attributes: [...store.attributes.entries()]
35
+ .map(([name, counter]) => ({
36
+ name,
37
+ template: counter.template,
38
+ calls: counter.calls,
39
+ totalDurationMs: counter.totalDurationMs,
40
+ maxDurationMs: counter.maxDurationMs,
41
+ placeholders: EvaluationProfileRegistry.sortPlaceholders(counter.placeholders),
42
+ }))
43
+ .sort((left, right) => right.calls - left.calls),
44
+ texts: [...store.texts.entries()]
45
+ .map(([childIndex, counter]) => ({
46
+ childIndex: Number(childIndex),
47
+ template: counter.template,
48
+ calls: counter.calls,
49
+ totalDurationMs: counter.totalDurationMs,
50
+ maxDurationMs: counter.maxDurationMs,
51
+ placeholders: EvaluationProfileRegistry.sortPlaceholders(counter.placeholders),
52
+ }))
53
+ .sort((left, right) => right.calls - left.calls),
54
+ }))
55
+ .sort((left, right) => {
56
+ const leftCalls = left.attributes.reduce((sum, item) => sum + item.calls, 0) +
57
+ left.texts.reduce((sum, item) => sum + item.calls, 0);
58
+ const rightCalls = right.attributes.reduce((sum, item) => sum + item.calls, 0) +
59
+ right.texts.reduce((sum, item) => sum + item.calls, 0);
60
+ return rightCalls - leftCalls;
61
+ });
62
+ }
63
+ /**
64
+ * 評価呼び出しを記録します。
65
+ *
66
+ * @param context 評価コンテキスト
67
+ * @param expressions 今回評価した式一覧
68
+ */
69
+ static record(context, expressions, totalDurationMs) {
70
+ if (!Dev.isEnabled() || !context || expressions.length === 0) {
71
+ return;
72
+ }
73
+ EvaluationProfileRegistry.ensureGlobalAccess();
74
+ const store = EvaluationProfileRegistry.getOrCreateElementStore(context.element);
75
+ if (context.kind === 'attribute') {
76
+ const counter = EvaluationProfileRegistry.getOrCreateCounter(store.attributes, context.rawName, context.template);
77
+ EvaluationProfileRegistry.updateCounter(counter, expressions, totalDurationMs);
78
+ return;
79
+ }
80
+ const counter = EvaluationProfileRegistry.getOrCreateCounter(store.texts, String(context.childIndex), context.template);
81
+ EvaluationProfileRegistry.updateCounter(counter, expressions, totalDurationMs);
82
+ }
83
+ /**
84
+ * globalThis から dev-only の取得窓口を参照できるようにします。
85
+ */
86
+ static ensureGlobalAccess() {
87
+ if (!Dev.isEnabled()) {
88
+ return;
89
+ }
90
+ const globalRecord = globalThis;
91
+ if (globalRecord[EvaluationProfileRegistry.GLOBAL_KEY] !== undefined) {
92
+ return;
93
+ }
94
+ globalRecord[EvaluationProfileRegistry.GLOBAL_KEY] = {
95
+ reset: () => EvaluationProfileRegistry.reset(),
96
+ snapshot: () => EvaluationProfileRegistry.snapshot(),
97
+ };
98
+ }
99
+ /**
100
+ * エレメント単位の集計ストアを取得または初期化します。
101
+ *
102
+ * @param element 対象エレメント
103
+ * @returns 集計ストア
104
+ */
105
+ static getOrCreateElementStore(element) {
106
+ const elementId = EvaluationProfileRegistry.createElementId(element);
107
+ const existing = EvaluationProfileRegistry.ELEMENT_STORES.get(elementId);
108
+ if (existing) {
109
+ return existing;
110
+ }
111
+ const store = {
112
+ tagName: element.tagName.toLowerCase(),
113
+ attributes: new Map(),
114
+ texts: new Map(),
115
+ };
116
+ EvaluationProfileRegistry.ELEMENT_STORES.set(elementId, store);
117
+ return store;
118
+ }
119
+ /**
120
+ * カウンタを取得または初期化します。
121
+ *
122
+ * @param counters 種別ごとのカウンタマップ
123
+ * @param key カウンタキー
124
+ * @param template 元テンプレート
125
+ * @returns カウンタ
126
+ */
127
+ static getOrCreateCounter(counters, key, template) {
128
+ const existing = counters.get(key);
129
+ if (existing) {
130
+ return existing;
131
+ }
132
+ const counter = {
133
+ template,
134
+ calls: 0,
135
+ totalDurationMs: 0,
136
+ maxDurationMs: 0,
137
+ placeholders: new Map(),
138
+ };
139
+ counters.set(key, counter);
140
+ return counter;
141
+ }
142
+ /**
143
+ * プレースホルダカウンタを取得または初期化します。
144
+ *
145
+ * @param placeholders プレースホルダカウンタマップ
146
+ * @param expression 式文字列
147
+ * @returns プレースホルダカウンタ
148
+ */
149
+ static getOrCreatePlaceholder(placeholders, expression) {
150
+ const existing = placeholders.get(expression);
151
+ if (existing) {
152
+ return existing;
153
+ }
154
+ const counter = {
155
+ calls: 0,
156
+ totalDurationMs: 0,
157
+ maxDurationMs: 0,
158
+ };
159
+ placeholders.set(expression, counter);
160
+ return counter;
161
+ }
162
+ /**
163
+ * カウンタへ今回の評価結果を加算します。
164
+ *
165
+ * @param counter 更新対象カウンタ
166
+ * @param expressions 今回評価した式一覧
167
+ * @param totalDurationMs 今回の総所要時間
168
+ */
169
+ static updateCounter(counter, expressions, totalDurationMs) {
170
+ counter.calls += 1;
171
+ counter.totalDurationMs += totalDurationMs;
172
+ counter.maxDurationMs = Math.max(counter.maxDurationMs, totalDurationMs);
173
+ expressions.forEach(expression => {
174
+ const placeholder = EvaluationProfileRegistry.getOrCreatePlaceholder(counter.placeholders, expression.expression);
175
+ placeholder.calls += 1;
176
+ placeholder.totalDurationMs += expression.durationMs;
177
+ placeholder.maxDurationMs = Math.max(placeholder.maxDurationMs, expression.durationMs);
178
+ });
179
+ }
180
+ /**
181
+ * プレースホルダ集計を calls 降順で返します。
182
+ *
183
+ * @param placeholders プレースホルダ集計
184
+ * @returns スナップショット
185
+ */
186
+ static sortPlaceholders(placeholders) {
187
+ return [...placeholders.entries()]
188
+ .map(([expression, counter]) => ({
189
+ expression,
190
+ calls: counter.calls,
191
+ totalDurationMs: counter.totalDurationMs,
192
+ maxDurationMs: counter.maxDurationMs,
193
+ }))
194
+ .sort((left, right) => {
195
+ if (right.calls !== left.calls) {
196
+ return right.calls - left.calls;
197
+ }
198
+ return right.totalDurationMs - left.totalDurationMs;
199
+ });
200
+ }
201
+ /**
202
+ * 現在時刻のタイムスタンプを返します。
203
+ *
204
+ * @returns ミリ秒
205
+ */
206
+ static now() {
207
+ return globalThis.performance?.now() ?? Date.now();
208
+ }
209
+ /**
210
+ * 評価処理の所要時間を計測します。
211
+ *
212
+ * @param callback 計測対象
213
+ * @returns 結果と所要時間
214
+ */
215
+ static measure(callback) {
216
+ const startedAt = EvaluationProfileRegistry.now();
217
+ const value = callback();
218
+ return {
219
+ value,
220
+ durationMs: EvaluationProfileRegistry.now() - startedAt,
221
+ };
222
+ }
223
+ /**
224
+ * エレメント識別子を生成します。
225
+ *
226
+ * @param element 対象エレメント
227
+ * @returns 識別子
228
+ */
229
+ static createElementId(element) {
230
+ const segments = [];
231
+ let current = element;
232
+ while (current) {
233
+ let segment = current.tagName.toLowerCase();
234
+ const rawId = current.getAttribute('id') || '';
235
+ if (rawId.trim() !== '') {
236
+ segment += `#${rawId.trim()}`;
237
+ segments.unshift(segment);
238
+ break;
239
+ }
240
+ const deriveName = current.getAttribute(`${Env.prefix}derive-name`);
241
+ if (deriveName && deriveName.trim() !== '') {
242
+ segment += `[${Env.prefix}derive-name="${deriveName.trim()}"]`;
243
+ }
244
+ const parent = current.parentElement;
245
+ if (parent) {
246
+ segment += `:nth-child(${[...parent.children].indexOf(current) + 1})`;
247
+ }
248
+ segments.unshift(segment);
249
+ current = parent;
250
+ }
251
+ return segments.join(' > ');
252
+ }
253
+ }
254
+ /** globalThis に公開するキー */
255
+ EvaluationProfileRegistry.GLOBAL_KEY = '__HAORI_EVALUATION_PROFILE__';
256
+ /** エレメントごとの集計 */
257
+ EvaluationProfileRegistry.ELEMENT_STORES = new Map();
11
258
  /**
12
259
  * 仮想DOMのフラグメントの抽象クラス。
13
260
  */
@@ -237,6 +484,10 @@ export class ElementFragment extends Fragment {
237
484
  this.renderSignature = null;
238
485
  /** 直近に描画した data-each 全体の入力署名 */
239
486
  this.eachInputSignature = null;
487
+ /** 直近に公開した data-derive subtree の入力署名 */
488
+ this.deriveSubtreeSignature = null;
489
+ /** 直近に評価した data-derive の入力署名 */
490
+ this.deriveInputSignature = null;
240
491
  /** fresh clone 初期化を subtree ごと省略できるかどうか */
241
492
  this.freshInitializationSkippable = false;
242
493
  /** valueプロパティの値 */
@@ -327,6 +578,8 @@ export class ElementFragment extends Fragment {
327
578
  clone.template = this.template;
328
579
  clone.renderSignature = this.renderSignature;
329
580
  clone.eachInputSignature = this.eachInputSignature;
581
+ clone.deriveSubtreeSignature = null;
582
+ clone.deriveInputSignature = null;
330
583
  clone.freshInitializationSkippable = this.freshInitializationSkippable;
331
584
  clone.normalizeClonedVisibilityState();
332
585
  return clone;
@@ -372,6 +625,8 @@ export class ElementFragment extends Fragment {
372
625
  this.template = null;
373
626
  }
374
627
  this.eachInputSignature = null;
628
+ this.deriveSubtreeSignature = null;
629
+ this.deriveInputSignature = null;
375
630
  promises.push(super.remove(unmount));
376
631
  return Promise.all(promises).then(() => undefined);
377
632
  }
@@ -538,6 +793,38 @@ export class ElementFragment extends Fragment {
538
793
  setEachInputSignature(signature) {
539
794
  this.eachInputSignature = signature;
540
795
  }
796
+ /**
797
+ * 直近に公開した data-derive subtree の入力署名を取得します。
798
+ *
799
+ * @returns 入力署名
800
+ */
801
+ getDeriveSubtreeSignature() {
802
+ return this.deriveSubtreeSignature;
803
+ }
804
+ /**
805
+ * 直近に公開した data-derive subtree の入力署名を設定します。
806
+ *
807
+ * @param signature 入力署名
808
+ */
809
+ setDeriveSubtreeSignature(signature) {
810
+ this.deriveSubtreeSignature = signature;
811
+ }
812
+ /**
813
+ * 直近に評価した data-derive の入力署名を取得します。
814
+ *
815
+ * @returns 入力署名
816
+ */
817
+ getDeriveInputSignature() {
818
+ return this.deriveInputSignature;
819
+ }
820
+ /**
821
+ * 直近に評価した data-derive の入力署名を設定します。
822
+ *
823
+ * @param signature 入力署名
824
+ */
825
+ setDeriveInputSignature(signature) {
826
+ this.deriveInputSignature = signature;
827
+ }
541
828
  /**
542
829
  * fresh clone 初期化を subtree ごと省略できるかどうかを返します。
543
830
  *
@@ -785,7 +1072,12 @@ export class ElementFragment extends Fragment {
785
1072
  }
786
1073
  this.attributeMap.set(rawName, contents);
787
1074
  const element = this.getTarget();
788
- const detail = contents.evaluateDetailed(this.getBindingData());
1075
+ const detail = contents.evaluateDetailed(this.getBindingData(), {
1076
+ kind: 'attribute',
1077
+ element,
1078
+ rawName,
1079
+ template: value,
1080
+ });
789
1081
  const hasTemplateExpression = contents.isEvaluate || contents.isRawEvaluate;
790
1082
  const isBooleanAttribute = rawName === targetName &&
791
1083
  ElementFragment.BOOLEAN_ATTRIBUTES.has(targetName.toLowerCase());
@@ -905,7 +1197,12 @@ export class ElementFragment extends Fragment {
905
1197
  if (contents === undefined) {
906
1198
  return null;
907
1199
  }
908
- const detail = contents.evaluateDetailed(this.getBindingData());
1200
+ const detail = contents.evaluateDetailed(this.getBindingData(), {
1201
+ kind: 'attribute',
1202
+ element: this.getTarget(),
1203
+ rawName: name,
1204
+ template: contents.getValue(),
1205
+ });
909
1206
  if (detail.results.length === 1) {
910
1207
  return {
911
1208
  value: detail.results[0],
@@ -1291,10 +1588,20 @@ export class TextFragment extends Fragment {
1291
1588
  this.skipMutation = true;
1292
1589
  let nextText = this.text;
1293
1590
  if (this.contents.isRawEvaluate) {
1294
- nextText = this.contents.evaluate(this.parent.getBindingData())[0];
1591
+ nextText = this.contents.evaluate(this.parent.getBindingData(), {
1592
+ kind: 'text',
1593
+ element: this.parent.getTarget(),
1594
+ childIndex: this.parent.getChildren().indexOf(this),
1595
+ template: this.text,
1596
+ })[0];
1295
1597
  }
1296
1598
  else if (this.contents.isEvaluate) {
1297
- nextText = TextContents.joinEvaluateResults(this.contents.evaluate(this.parent.getBindingData()));
1599
+ nextText = TextContents.joinEvaluateResults(this.contents.evaluate(this.parent.getBindingData(), {
1600
+ kind: 'text',
1601
+ element: this.parent.getTarget(),
1602
+ childIndex: this.parent.getChildren().indexOf(this),
1603
+ template: this.text,
1604
+ }));
1298
1605
  }
1299
1606
  const currentText = this.contents.isRawEvaluate
1300
1607
  ? this.parent.getTarget().innerHTML
@@ -1496,8 +1803,8 @@ class TextContents {
1496
1803
  * @param bindingValues バインディングされた値のオブジェクト
1497
1804
  * @returns 評価結果のリスト
1498
1805
  */
1499
- evaluate(bindingValues) {
1500
- return this.evaluateDetailed(bindingValues).results;
1806
+ evaluate(bindingValues, profileContext) {
1807
+ return this.evaluateDetailed(bindingValues, profileContext).results;
1501
1808
  }
1502
1809
  /**
1503
1810
  * 式評価を行い、未解決参照の有無を含む結果を返します。
@@ -1505,33 +1812,58 @@ class TextContents {
1505
1812
  * @param bindingValues バインディングされた値のオブジェクト
1506
1813
  * @returns 評価結果と未解決参照の有無
1507
1814
  */
1508
- evaluateDetailed(bindingValues) {
1815
+ evaluateDetailed(bindingValues, profileContext) {
1509
1816
  if (!this.isEvaluate && !this.isRawEvaluate) {
1510
1817
  return {
1511
1818
  results: this.contents.map(c => c.text),
1512
1819
  hasUnresolvedReference: false,
1513
1820
  };
1514
1821
  }
1822
+ return this.evaluateWithProfile(bindingValues, profileContext, content => content.type === ExpressionType.EXPRESSION ||
1823
+ content.type === ExpressionType.RAW_EXPRESSION, 'text');
1824
+ }
1825
+ /**
1826
+ * 式評価と profiler 記録をまとめて実行します。
1827
+ *
1828
+ * @param bindingValues バインディングされた値のオブジェクト
1829
+ * @param profileContext profiler 用コンテキスト
1830
+ * @param shouldEvaluate 評価対象判定
1831
+ * @param errorKind エラーログ種別
1832
+ * @returns 評価結果と未解決参照の有無
1833
+ */
1834
+ evaluateWithProfile(bindingValues, profileContext, shouldEvaluate, errorKind) {
1515
1835
  const results = [];
1836
+ const profileExpressions = [];
1837
+ let totalDurationMs = 0;
1516
1838
  let hasUnresolvedReference = false;
1517
- this.contents.forEach(c => {
1839
+ this.contents.forEach(content => {
1518
1840
  try {
1519
- if (c.type === ExpressionType.EXPRESSION ||
1520
- c.type === ExpressionType.RAW_EXPRESSION) {
1521
- const result = Expression.evaluateDetailed(c.text, bindingValues);
1841
+ if (shouldEvaluate(content)) {
1842
+ const measured = EvaluationProfileRegistry.measure(() => Expression.evaluateDetailed(content.text, bindingValues));
1843
+ const result = measured.value;
1844
+ totalDurationMs += measured.durationMs;
1845
+ profileExpressions.push({
1846
+ expression: content.text,
1847
+ durationMs: measured.durationMs,
1848
+ });
1522
1849
  hasUnresolvedReference =
1523
1850
  hasUnresolvedReference || result.unresolvedReference;
1524
1851
  results.push(result.value);
1525
1852
  }
1526
1853
  else {
1527
- results.push(c.text);
1854
+ results.push(content.text);
1528
1855
  }
1529
1856
  }
1530
1857
  catch (error) {
1531
- Log.error('[Haori]', `Error evaluating text expression: ${c.text}`, error);
1858
+ Log.error('[Haori]', `Error evaluating ${errorKind} expression: ${content.text}`, error);
1859
+ profileExpressions.push({
1860
+ expression: content.text,
1861
+ durationMs: 0,
1862
+ });
1532
1863
  results.push('');
1533
1864
  }
1534
1865
  });
1866
+ EvaluationProfileRegistry.record(profileContext, profileExpressions, totalDurationMs);
1535
1867
  return { results, hasUnresolvedReference };
1536
1868
  }
1537
1869
  }
@@ -1567,8 +1899,8 @@ class AttributeContents extends TextContents {
1567
1899
  * @param bindingValues バインディングされた値のオブジェクト
1568
1900
  * @returns 評価結果のリスト
1569
1901
  */
1570
- evaluate(bindingValues) {
1571
- return this.evaluateDetailed(bindingValues).results;
1902
+ evaluate(bindingValues, profileContext) {
1903
+ return this.evaluateDetailed(bindingValues, profileContext).results;
1572
1904
  }
1573
1905
  /**
1574
1906
  * 式評価を行い、未解決参照の有無を含む結果を返します。
@@ -1576,42 +1908,24 @@ class AttributeContents extends TextContents {
1576
1908
  * @param bindingValues バインディングされた値のオブジェクト
1577
1909
  * @returns 評価結果と未解決参照の有無
1578
1910
  */
1579
- evaluateDetailed(bindingValues) {
1911
+ evaluateDetailed(bindingValues, profileContext) {
1580
1912
  if (!this.isEvaluate && !this.forceEvaluation) {
1581
1913
  return {
1582
1914
  results: this.contents.map(c => c.text),
1583
1915
  hasUnresolvedReference: false,
1584
1916
  };
1585
1917
  }
1586
- const results = [];
1587
- let hasUnresolvedReference = false;
1588
- this.contents.forEach(c => {
1589
- try {
1590
- if ((this.forceEvaluation && c.type === ExpressionType.TEXT) ||
1591
- c.type === ExpressionType.EXPRESSION ||
1592
- c.type === ExpressionType.RAW_EXPRESSION) {
1593
- const result = Expression.evaluateDetailed(c.text, bindingValues);
1594
- hasUnresolvedReference =
1595
- hasUnresolvedReference || result.unresolvedReference;
1596
- results.push(result.value);
1597
- }
1598
- else {
1599
- results.push(c.text);
1600
- }
1601
- }
1602
- catch (error) {
1603
- Log.error('[Haori]', `Error evaluating attribute expression: ${c.text}`, error);
1604
- results.push('');
1605
- }
1606
- });
1607
- if (this.forceEvaluation && results.length > 1) {
1608
- Log.error('[Haori]', 'each or if expressions must have a single content.', results);
1918
+ const detail = this.evaluateWithProfile(bindingValues, profileContext, content => (this.forceEvaluation && content.type === ExpressionType.TEXT) ||
1919
+ content.type === ExpressionType.EXPRESSION ||
1920
+ content.type === ExpressionType.RAW_EXPRESSION, 'attribute');
1921
+ if (this.forceEvaluation && detail.results.length > 1) {
1922
+ Log.error('[Haori]', 'each or if expressions must have a single content.', detail.results);
1609
1923
  return {
1610
- results: [results[0]],
1611
- hasUnresolvedReference,
1924
+ results: [detail.results[0]],
1925
+ hasUnresolvedReference: detail.hasUnresolvedReference,
1612
1926
  };
1613
1927
  }
1614
- return { results, hasUnresolvedReference };
1928
+ return detail;
1615
1929
  }
1616
1930
  }
1617
1931
  /** 強制評価する属性名 */