synstate-dom 0.5.0 → 0.7.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 (59) hide show
  1. package/README.md +1 -0
  2. package/dist/control/for.d.mts +4 -0
  3. package/dist/control/for.d.mts.map +1 -1
  4. package/dist/control/for.mjs +25 -15
  5. package/dist/control/for.mjs.map +1 -1
  6. package/dist/control/mount-switch.d.mts +4 -0
  7. package/dist/control/mount-switch.d.mts.map +1 -1
  8. package/dist/control/mount-switch.mjs +25 -12
  9. package/dist/control/mount-switch.mjs.map +1 -1
  10. package/dist/control/portal.d.mts.map +1 -1
  11. package/dist/control/portal.mjs +2 -2
  12. package/dist/control/portal.mjs.map +1 -1
  13. package/dist/index.d.mts +1 -0
  14. package/dist/index.d.mts.map +1 -1
  15. package/dist/index.mjs +1 -0
  16. package/dist/index.mjs.map +1 -1
  17. package/dist/internal/after-attach.d.mts +35 -0
  18. package/dist/internal/after-attach.d.mts.map +1 -0
  19. package/dist/internal/after-attach.mjs +122 -0
  20. package/dist/internal/after-attach.mjs.map +1 -0
  21. package/dist/internal/as-constant.d.mts +4 -4
  22. package/dist/internal/dom-tables.d.mts.map +1 -1
  23. package/dist/internal/dom-tables.mjs +3 -0
  24. package/dist/internal/dom-tables.mjs.map +1 -1
  25. package/dist/internal/index.d.mts +1 -0
  26. package/dist/internal/index.d.mts.map +1 -1
  27. package/dist/internal/index.mjs +1 -0
  28. package/dist/internal/index.mjs.map +1 -1
  29. package/dist/jsx/element-props.d.mts +15 -2
  30. package/dist/jsx/element-props.d.mts.map +1 -1
  31. package/dist/jsx/jsx.mjs +2 -2
  32. package/dist/jsx/jsx.mjs.map +1 -1
  33. package/dist/keyboard/index.d.mts +2 -0
  34. package/dist/keyboard/index.d.mts.map +1 -0
  35. package/dist/keyboard/index.mjs +2 -0
  36. package/dist/keyboard/index.mjs.map +1 -0
  37. package/dist/keyboard/is-ime-key-down.d.mts +11 -0
  38. package/dist/keyboard/is-ime-key-down.d.mts.map +1 -0
  39. package/dist/keyboard/is-ime-key-down.mjs +15 -0
  40. package/dist/keyboard/is-ime-key-down.mjs.map +1 -0
  41. package/dist/render.d.mts +2 -1
  42. package/dist/render.d.mts.map +1 -1
  43. package/dist/render.mjs +19 -4
  44. package/dist/render.mjs.map +1 -1
  45. package/package.json +10 -10
  46. package/src/control/for.mts +41 -19
  47. package/src/control/mount-switch.mts +36 -15
  48. package/src/control/portal.mts +7 -3
  49. package/src/index.mts +1 -0
  50. package/src/internal/after-attach.mts +173 -0
  51. package/src/internal/as-constant.mts +4 -4
  52. package/src/internal/dom-tables.mts +3 -0
  53. package/src/internal/index.mts +1 -0
  54. package/src/jsx/element-props.mts +39 -2
  55. package/src/jsx/jsx.mts +2 -2
  56. package/src/keyboard/index.mts +1 -0
  57. package/src/keyboard/is-ime-key-down.mts +20 -0
  58. package/src/keyboard/is-ime-key-down.test.mts +42 -0
  59. package/src/render.mts +32 -6
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.mts","sourceRoot":"","sources":["../../src/keyboard/index.mts"],"names":[],"mappings":"AAAA,cAAc,uBAAuB,CAAC"}
@@ -0,0 +1,2 @@
1
+ export * from './is-ime-key-down.mjs';
2
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","sourceRoot":"","sources":["../../src/keyboard/index.mts"],"names":[],"mappings":"AAAA,cAAc,uBAAuB,CAAC"}
@@ -0,0 +1,11 @@
1
+ import type { StrictPick } from 'ts-type-forge';
2
+ /**
3
+ * Whether a key down is an input method's rather than the field's: one sent
4
+ * while a composition is going on, or the Enter or Escape that confirmed or
5
+ * cancelled one, which Safari sends after `compositionend`, told from the
6
+ * field's by its key code alone. Other keys with that key code are the field's:
7
+ * a virtual keyboard on Android sends it for the keys that type text, and its
8
+ * arrow keys may carry it too.
9
+ */
10
+ export declare const isImeKeyDown: (event: StrictPick<KeyboardEvent, 'isComposing' | 'key' | 'keyCode'>) => boolean;
11
+ //# sourceMappingURL=is-ime-key-down.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"is-ime-key-down.d.mts","sourceRoot":"","sources":["../../src/keyboard/is-ime-key-down.mts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAEhD;;;;;;;GAOG;AACH,eAAO,MAAM,YAAY,UAChB,UAAU,CAAC,aAAa,EAAE,aAAa,GAAG,KAAK,GAAG,SAAS,CAAC,KAClE,OAI8B,CAAC"}
@@ -0,0 +1,15 @@
1
+
2
+
3
+
4
+
5
+
6
+
7
+
8
+
9
+ export const isImeKeyDown = (event) => event.isComposing ||
10
+ ((event.key === 'Enter' || event.key === 'Escape') &&
11
+
12
+ event.keyCode === imeKeyCode);
13
+
14
+ const imeKeyCode = 229;
15
+ //# sourceMappingURL=is-ime-key-down.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"is-ime-key-down.mjs","sourceRoot":"","sources":["../../src/keyboard/is-ime-key-down.mts"],"names":[],"mappings":"AAEA;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,YAAY,GAAG,CAC1B,KAAmE,EAC1D,EAAE,CACX,KAAK,CAAC,WAAW;IACjB,CAAC,CAAC,KAAK,CAAC,GAAG,KAAK,OAAO,IAAI,KAAK,CAAC,GAAG,KAAK,QAAQ,CAAC;QAChD,6GAA6G;QAC7G,KAAK,CAAC,OAAO,KAAK,UAAU,CAAC,CAAC;AAElC,6DAA6D;AAC7D,MAAM,UAAU,GAAG,GAAG,CAAC"}
package/dist/render.d.mts CHANGED
@@ -5,7 +5,8 @@ import { type JSX } from './jsx/index.mjs';
5
5
  *
6
6
  * `app` is called once, inside a new root scope; everything created while it
7
7
  * and the description it returns are mounted belongs to that scope, and
8
- * unmounting disposes it and removes the nodes.
8
+ * unmounting disposes it and removes the nodes. If mounting throws, what was
9
+ * mounted is disposed and removed before the error is rethrown.
9
10
  *
10
11
  * `render(App, el)` rather than `render(<App />, el)`: taking the component
11
12
  * makes it plain that it is evaluated here.
@@ -1 +1 @@
1
- {"version":3,"file":"render.d.mts","sourceRoot":"","sources":["../src/render.mts"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,GAAG,EAAW,MAAM,iBAAiB,CAAC;AAEpD;;;;;;;;;;GAUG;AACH,eAAO,MAAM,MAAM,QACZ,MAAM,GAAG,CAAC,OAAO,aACX,OAAO,KACjB,CAAC,MAAM,IAAI,CAcb,CAAC"}
1
+ {"version":3,"file":"render.d.mts","sourceRoot":"","sources":["../src/render.mts"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,GAAG,EAAW,MAAM,iBAAiB,CAAC;AAEpD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,MAAM,QACZ,MAAM,GAAG,CAAC,OAAO,aACX,OAAO,KACjB,CAAC,MAAM,IAAI,CAmCb,CAAC"}
package/dist/render.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createScope } from 'synstate';
2
+ import { appendTo, mountThenInsert } from './internal/index.mjs';
2
3
  import { toNodes } from './jsx/index.mjs';
3
4
 
4
5
 
@@ -11,15 +12,29 @@ import { toNodes } from './jsx/index.mjs';
11
12
 
12
13
 
13
14
 
15
+
14
16
  export const render = (app, container) => {
15
17
  const root = createScope(undefined);
16
- const nodes = root.run(() => toNodes(app().mount()));
17
- container.append(...nodes);
18
- return () => {
18
+ const mut_inserted = { nodes: [] };
19
+ const unmount = () => {
19
20
  root.dispose();
20
- for (const node of nodes) {
21
+ for (const node of mut_inserted.nodes) {
21
22
  node.remove();
22
23
  }
23
24
  };
25
+ try {
26
+ root.run(() => mountThenInsert(mountApp, app, (into, nodes) => {
27
+ mut_inserted.nodes = nodes;
28
+ appendTo(into, nodes);
29
+ }, container));
30
+ }
31
+ catch (error) {
32
+
33
+
34
+ unmount();
35
+ throw error;
36
+ }
37
+ return unmount;
24
38
  };
39
+ const mountApp = (app) => toNodes(app().mount());
25
40
  //# sourceMappingURL=render.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"render.mjs","sourceRoot":"","sources":["../src/render.mts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AACvC,OAAO,EAAY,OAAO,EAAE,MAAM,iBAAiB,CAAC;AAEpD;;;;;;;;;;GAUG;AACH,MAAM,CAAC,MAAM,MAAM,GAAG,CACpB,GAAsB,EACtB,SAAkB,EACJ,EAAE;IAChB,MAAM,IAAI,GAAG,WAAW,CAAC,SAAS,CAAC,CAAC;IAEpC,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC;IAErD,SAAS,CAAC,MAAM,CAAC,GAAG,KAAK,CAAC,CAAC;IAE3B,OAAO,GAAG,EAAE;QACV,IAAI,CAAC,OAAO,EAAE,CAAC;QAEf,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;YACzB,IAAI,CAAC,MAAM,EAAE,CAAC;QAChB,CAAC;IACH,CAAC,CAAC;AACJ,CAAC,CAAC"}
1
+ {"version":3,"file":"render.mjs","sourceRoot":"","sources":["../src/render.mts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AACvC,OAAO,EAAE,QAAQ,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AACjE,OAAO,EAAY,OAAO,EAAE,MAAM,iBAAiB,CAAC;AAEpD;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,MAAM,MAAM,GAAG,CACpB,GAAsB,EACtB,SAAkB,EACJ,EAAE;IAChB,MAAM,IAAI,GAAG,WAAW,CAAC,SAAS,CAAC,CAAC;IAEpC,MAAM,YAAY,GAAoC,EAAE,KAAK,EAAE,EAAE,EAAE,CAAC;IAEpE,MAAM,OAAO,GAAG,GAAS,EAAE;QACzB,IAAI,CAAC,OAAO,EAAE,CAAC;QAEf,KAAK,MAAM,IAAI,IAAI,YAAY,CAAC,KAAK,EAAE,CAAC;YACtC,IAAI,CAAC,MAAM,EAAE,CAAC;QAChB,CAAC;IACH,CAAC,CAAC;IAEF,IAAI,CAAC;QACH,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,CACZ,eAAe,CACb,QAAQ,EACR,GAAG,EACH,CAAC,IAAa,EAAE,KAA2B,EAAE,EAAE;YAC7C,YAAY,CAAC,KAAK,GAAG,KAAK,CAAC;YAE3B,QAAQ,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;QACxB,CAAC,EACD,SAAS,CACV,CACF,CAAC;IACJ,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,yEAAyE;QACzE,0EAA0E;QAC1E,OAAO,EAAE,CAAC;QAEV,MAAM,KAAK,CAAC;IACd,CAAC;IAED,OAAO,OAAO,CAAC;AACjB,CAAC,CAAC;AAEF,MAAM,QAAQ,GAAG,CAAC,GAAsB,EAAwB,EAAE,CAChE,OAAO,CAAC,GAAG,EAAE,CAAC,KAAK,EAAE,CAAC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synstate-dom",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Compiler-free fine-grained UI library that binds synstate observables directly to the DOM",
5
5
  "repository": {
6
6
  "type": "git",
@@ -44,21 +44,21 @@
44
44
  },
45
45
  "devDependencies": {
46
46
  "@vitest/browser-playwright": "5.0.5",
47
- "eslint-config-typed": "7.3.0",
48
- "eslint-plugin-ts-data-forge": "0.10.7",
49
- "eslint-plugin-ts-fortress": "1.3.12",
50
- "eslint-plugin-ts-type-forge": "0.8.17",
47
+ "eslint-config-typed": "7.4.0",
48
+ "eslint-plugin-ts-data-forge": "0.10.9",
49
+ "eslint-plugin-ts-fortress": "1.3.13",
50
+ "eslint-plugin-ts-type-forge": "0.8.18",
51
51
  "playwright": "1.64.1",
52
52
  "prettier": "3.9.10",
53
53
  "prettier-plugin-organize-imports": "4.3.0",
54
54
  "prettier-plugin-packagejson": "3.0.2",
55
- "synstate": "6.1.1",
56
- "ts-data-forge": "14.10.1",
57
- "ts-repo-utils": "10.7.1"
55
+ "synstate": "7.0.0",
56
+ "ts-data-forge": "14.11.0",
57
+ "ts-repo-utils": "10.7.2"
58
58
  },
59
59
  "peerDependencies": {
60
- "synstate": ">=6.1.1",
61
- "ts-data-forge": ">=14.10.1"
60
+ "synstate": ">=7.0.0",
61
+ "ts-data-forge": ">=14.11.0"
62
62
  },
63
63
  "engines": {
64
64
  "node": ">=22.23.4",
@@ -11,7 +11,12 @@ import {
11
11
  import { Arr, Optional } from 'ts-data-forge';
12
12
  import type { BivariantHack } from 'ts-type-forge';
13
13
  import { toObservable, type Bindable } from '../constant/index.mjs';
14
- import { bindValue } from '../internal/index.mjs';
14
+ import {
15
+ applyWhenAttached,
16
+ bindValue,
17
+ insertBefore,
18
+ mountThenInsert,
19
+ } from '../internal/index.mjs';
15
20
  import { mountable, toNodes, type JSX } from '../jsx/index.mjs';
16
21
  import { longestIncreasingSubsequence } from './longest-increasing-subsequence.mjs';
17
22
 
@@ -31,6 +36,10 @@ import { longestIncreasingSubsequence } from './longest-increasing-subsequence.m
31
36
  * nodes; the rows that stay are moved as little as the longest increasing
32
37
  * subsequence allows.
33
38
  *
39
+ * An update that arrives before the anchor is in a parent (a later sibling
40
+ * wrote `items` while the enclosing element was still mounting its children) is
41
+ * applied, with the latest `items`, once the nodes `For` returned are inserted.
42
+ *
34
43
  * `key` is required, as in React; `(_, i) => i` matches rows by position. A key
35
44
  * that appears twice is reported with `console.error`, and its later
36
45
  * occurrences are rebuilt on every update.
@@ -72,7 +81,10 @@ export const For = <T,>({
72
81
 
73
82
  const rowStarts = new WeakSet<Node>();
74
83
 
75
- const mut_initial: { nodes: readonly ChildNode[] } = { nodes: [] };
84
+ const mut_initial: { nodes: readonly ChildNode[]; built: boolean } = {
85
+ nodes: [],
86
+ built: false,
87
+ };
76
88
 
77
89
  /** Creates a row in a scope of its own and returns its first nodes. */
78
90
  const createRow = (
@@ -161,12 +173,11 @@ export const For = <T,>({
161
173
  range.deleteContents();
162
174
  };
163
175
 
164
- bindValue(entries$, (entries) => {
176
+ /** Brings the attached rows in line with `entries`. */
177
+ const update = (entries: Entries<T>): void => {
165
178
  const previousOrder = Array.from(mut_rows.keys());
166
179
 
167
- if (anchor.parentNode !== null) {
168
- removeAllAtOnceIfEveryKeyGoes(previousOrder, entries);
169
- }
180
+ removeAllAtOnceIfEveryKeyGoes(previousOrder, entries);
170
181
 
171
182
  for (const [rowKey, row] of mut_rows) {
172
183
  if (entries.byKey.has(rowKey)) {
@@ -178,16 +189,6 @@ export const For = <T,>({
178
189
  mut_rows.delete(rowKey);
179
190
  }
180
191
 
181
- if (anchor.parentNode === null) {
182
- mut_initial.nodes = entries.order.flatMap((rowKey) => {
183
- const entry = entries.byKey.get(rowKey);
184
-
185
- return entry === undefined ? [] : createRow(rowKey, entry);
186
- });
187
-
188
- return;
189
- }
190
-
191
192
  if (Arr.eq(previousOrder, entries.order)) {
192
193
  return;
193
194
  }
@@ -224,11 +225,14 @@ export const For = <T,>({
224
225
  continue;
225
226
  }
226
227
 
227
- const [start, ...rest] = createRow(rowKey, entry);
228
+ const [start] = mountThenInsert(
229
+ (e: Entry<T>) => createRow(rowKey, e),
230
+ entry,
231
+ insertBefore,
232
+ mut_next,
233
+ );
228
234
 
229
235
  if (start !== undefined) {
230
- mut_next.before(start, ...rest);
231
-
232
236
  mut_next = start;
233
237
  }
234
238
 
@@ -243,6 +247,24 @@ export const For = <T,>({
243
247
  }
244
248
 
245
249
  reorderMap(mut_rows, entries.order);
250
+ };
251
+
252
+ const updateWhenAttached = applyWhenAttached(anchor, update);
253
+
254
+ bindValue(entries$, (entries) => {
255
+ if (!mut_initial.built) {
256
+ mut_initial.built = true;
257
+
258
+ mut_initial.nodes = entries.order.flatMap((rowKey) => {
259
+ const entry = entries.byKey.get(rowKey);
260
+
261
+ return entry === undefined ? [] : createRow(rowKey, entry);
262
+ });
263
+
264
+ return;
265
+ }
266
+
267
+ updateWhenAttached(entries);
246
268
  });
247
269
 
248
270
  return Arr.toPushed(mut_initial.nodes, anchor);
@@ -6,7 +6,12 @@ import {
6
6
  } from 'synstate';
7
7
  import { Arr } from 'ts-data-forge';
8
8
  import type { Bindable } from '../constant/index.mjs';
9
- import { bindValue } from '../internal/index.mjs';
9
+ import {
10
+ applyWhenAttached,
11
+ bindValue,
12
+ insertBefore,
13
+ mountThenInsert,
14
+ } from '../internal/index.mjs';
10
15
  import { toNodes, type Mountable, type MountedNodes } from '../jsx/index.mjs';
11
16
 
12
17
  /**
@@ -22,6 +27,10 @@ import { toNodes, type Mountable, type MountedNodes } from '../jsx/index.mjs';
22
27
  *
23
28
  * `branch` runs inside the new scope, so whatever it creates belongs to the
24
29
  * branch.
30
+ *
31
+ * A value that arrives before the anchor is in a parent (a later sibling wrote
32
+ * it while the enclosing element was still mounting its children) is kept, and
33
+ * the switch follows the latest one once the nodes it returned are inserted.
25
34
  */
26
35
  export const mountSwitch = <V, K>(
27
36
  value: Bindable<V>,
@@ -37,17 +46,8 @@ export const mountSwitch = <V, K>(
37
46
  initialNodes: readonly ChildNode[];
38
47
  } = { current: undefined, initialNodes: [] };
39
48
 
40
- bindValue(value, (v) => {
41
- const key = keyOf(v);
42
-
43
- if (mut_state.current !== undefined) {
44
- if (Object.is(mut_state.current.key, key)) {
45
- return;
46
- }
47
-
48
- mut_state.current.scope.dispose();
49
- }
50
-
49
+ /** Mounts the branch for `v` in a scope of its own and returns its nodes. */
50
+ const mountBranch = (key: K, v: V): readonly ChildNode[] => {
51
51
  const scope = createScope(parent);
52
52
 
53
53
  const nodes = scope.run(() => {
@@ -64,10 +64,31 @@ export const mountSwitch = <V, K>(
64
64
 
65
65
  mut_state.current = { key, scope };
66
66
 
67
- if (anchor.parentNode === null) {
68
- mut_state.initialNodes = nodes;
67
+ return nodes;
68
+ };
69
+
70
+ /** Switches the attached switch to the branch for `v`. */
71
+ const switchTo = (v: V): void => {
72
+ const key = keyOf(v);
73
+
74
+ if (mut_state.current !== undefined) {
75
+ if (Object.is(mut_state.current.key, key)) {
76
+ return;
77
+ }
78
+
79
+ mut_state.current.scope.dispose();
80
+ }
81
+
82
+ mountThenInsert((k: K) => mountBranch(k, v), key, insertBefore, anchor);
83
+ };
84
+
85
+ const switchWhenAttached = applyWhenAttached(anchor, switchTo);
86
+
87
+ bindValue(value, (v) => {
88
+ if (mut_state.current === undefined) {
89
+ mut_state.initialNodes = mountBranch(keyOf(v), v);
69
90
  } else {
70
- anchor.before(...nodes);
91
+ switchWhenAttached(v);
71
92
  }
72
93
  });
73
94
 
@@ -1,4 +1,5 @@
1
1
  import { onScopeDispose } from 'synstate';
2
+ import { appendTo, mountThenInsert } from '../internal/index.mjs';
2
3
  import { mountChild, mountable, type Child, type JSX } from '../jsx/index.mjs';
3
4
 
4
5
  /**
@@ -18,9 +19,12 @@ export const Portal = ({
18
19
  children: Child;
19
20
  }>): JSX.Element =>
20
21
  mountable(() => {
21
- const nodes = mountChild(children);
22
-
23
- (target ?? document.body).append(...nodes);
22
+ const nodes = mountThenInsert(
23
+ mountChild,
24
+ children,
25
+ appendTo,
26
+ target ?? document.body,
27
+ );
24
28
 
25
29
  onScopeDispose(() => {
26
30
  for (const node of nodes) {
package/src/index.mts CHANGED
@@ -1,5 +1,6 @@
1
1
  export * from './constant/index.mjs';
2
2
  export * from './control/index.mjs';
3
3
  export * from './jsx/index.mjs';
4
+ export * from './keyboard/index.mjs';
4
5
  export * from './render.mjs';
5
6
  export * from './scope/index.mjs';
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Nodes are mounted before they are inserted: an element mounts all its
3
+ * children and then appends them, `render` mounts the app and then appends it,
4
+ * and `For` and `If` mount a row or a branch and then insert it. A child
5
+ * mounted later that writes to what an earlier `For` or `If` reads updates them
6
+ * while their anchor is in no parent yet (#2935).
7
+ *
8
+ * Every mount-and-insert goes through {@link mountThenInsert}. A `For` or a
9
+ * switch takes {@link applyWhenAttached} when it is mounted, which notes the
10
+ * mount open then; a value that arrives while its anchor is detached is kept,
11
+ * and the latest one is applied once that mount has inserted its nodes.
12
+ *
13
+ * `mount` takes `arg` rather than closing over it, so that the element and
14
+ * `render` paths allocate nothing; a mount that nothing defers to allocates no
15
+ * frame.
16
+ */
17
+ export const mountThenInsert = <A, N, T>(
18
+ mount: (input: A) => N,
19
+ arg: A,
20
+ insert: (into: T, mounted: N) => void,
21
+ target: T,
22
+ ): N => {
23
+ mut_state.depth += 1;
24
+
25
+ mut_state.lastId += 1;
26
+
27
+ const { depth } = mut_state;
28
+
29
+ mut_state.ids[depth] = mut_state.lastId;
30
+
31
+ // Left behind by a mount at this depth that threw.
32
+ takeFrame(depth);
33
+
34
+ const nodes = runMount(mount, arg, depth);
35
+
36
+ const frame = takeFrame(depth);
37
+
38
+ insert(target, nodes);
39
+
40
+ if (frame !== undefined) {
41
+ runAll(frame);
42
+ }
43
+
44
+ return nodes;
45
+ };
46
+
47
+ /** Appends `nodes` to `parent`: an `insert` for {@link mountThenInsert}. */
48
+ export const appendTo = (
49
+ parent: Element,
50
+ nodes: readonly ChildNode[],
51
+ ): void => {
52
+ parent.append(...nodes);
53
+ };
54
+
55
+ /** Inserts `nodes` in front of `next`: an `insert` for {@link mountThenInsert}. */
56
+ export const insertBefore = (
57
+ next: ChildNode,
58
+ nodes: readonly ChildNode[],
59
+ ): void => {
60
+ next.before(...nodes);
61
+ };
62
+
63
+ /**
64
+ * Returns the function a `For` or a switch calls with each value after its
65
+ * first. While `anchor` is attached the value is applied at once, and replaces
66
+ * any value still waiting. While it is detached the value is kept, and the
67
+ * latest one is applied once the mount open now has inserted its nodes, and
68
+ * only if `anchor` is then attached: it is not when the `For` or switch was
69
+ * disposed in between.
70
+ *
71
+ * Mounted outside every {@link mountThenInsert} (a description mounted by hand),
72
+ * there is nothing to wait for: a value that arrives while `anchor` is detached
73
+ * is dropped.
74
+ */
75
+ export const applyWhenAttached = <V,>(
76
+ anchor: Node,
77
+ apply: (value: V) => void,
78
+ ): ((value: V) => void) => {
79
+ const { depth } = mut_state;
80
+
81
+ const id = mut_state.ids[depth];
82
+
83
+ const mut_pending: { latest: Readonly<{ value: V }> | undefined } = {
84
+ latest: undefined,
85
+ };
86
+
87
+ const flush = (): void => {
88
+ const { latest } = mut_pending;
89
+
90
+ mut_pending.latest = undefined;
91
+
92
+ if (latest !== undefined && anchor.parentNode !== null) {
93
+ apply(latest.value);
94
+ }
95
+ };
96
+
97
+ return (value) => {
98
+ if (anchor.parentNode !== null) {
99
+ mut_pending.latest = undefined;
100
+
101
+ apply(value);
102
+
103
+ return;
104
+ }
105
+
106
+ const scheduled = mut_pending.latest !== undefined;
107
+
108
+ mut_pending.latest = { value };
109
+
110
+ if (scheduled) {
111
+ return;
112
+ }
113
+
114
+ if (depth === 0 || mut_state.depth < depth || mut_state.ids[depth] !== id) {
115
+ // That mount has ended: its nodes are inserted.
116
+ flush();
117
+
118
+ return;
119
+ }
120
+
121
+ const mut_frame = mut_state.frames[depth];
122
+
123
+ if (mut_frame === undefined) {
124
+ mut_state.frames[depth] = [flush];
125
+ } else {
126
+ mut_frame.push(flush);
127
+ }
128
+ };
129
+ };
130
+
131
+ const mut_state: {
132
+ depth: number;
133
+ lastId: number;
134
+ /** The id of the mount open at each depth. */
135
+ ids: number[];
136
+ /** What was deferred to the mount open at each depth, in order. */
137
+ frames: ((() => void)[] | undefined)[];
138
+ } = { depth: 0, lastId: 0, ids: [0], frames: [] };
139
+
140
+ /** Takes what was deferred to the mount at `depth` out of the state. */
141
+ const takeFrame = (depth: number): readonly (() => void)[] | undefined => {
142
+ const frame = mut_state.frames[depth];
143
+
144
+ mut_state.frames[depth] = undefined;
145
+
146
+ return frame;
147
+ };
148
+
149
+ /**
150
+ * Runs what was deferred to a mount. One that throws does not keep the rest
151
+ * from running, or they would keep their value waiting for good; the first
152
+ * error is rethrown once they have run.
153
+ */
154
+ const runAll = (frame: readonly (() => void)[]): void => {
155
+ for (const [index, apply] of frame.entries()) {
156
+ try {
157
+ apply();
158
+ } catch (error) {
159
+ runAll(frame.slice(index + 1));
160
+
161
+ throw error;
162
+ }
163
+ }
164
+ };
165
+
166
+ /** A mount that throws must not leave the depth raised. */
167
+ const runMount = <A, N>(mount: (input: A) => N, arg: A, depth: number): N => {
168
+ try {
169
+ return mount(arg);
170
+ } finally {
171
+ mut_state.depth = depth - 1;
172
+ }
173
+ };
@@ -1,10 +1,10 @@
1
1
  import type { Constant } from '../constant/index.mjs';
2
2
 
3
3
  /**
4
- * The one type assertion in the library. `Constant<T>` is a brand that exists
5
- * only in the type system, so marking a value as one changes nothing at run
6
- * time; this is what {@link constant} and the helpers that derive one constant
7
- * from another call.
4
+ * The one type assertion that brands a value. `Constant<T>` is a brand that
5
+ * exists only in the type system, so marking a value as one changes nothing at
6
+ * run time; this is what {@link constant} and the helpers that derive one
7
+ * constant from another call.
8
8
  */
9
9
  export const asConstant = <T,>(value: T): Constant<T> =>
10
10
  // eslint-disable-next-line total-functions/no-unsafe-type-assertion -- a brand has no run-time part to check
@@ -28,6 +28,9 @@ export const propertyNames = [
28
28
  */
29
29
  export const attributeNameOf: ReadonlyRecord<string, string> = {
30
30
  acceptCharset: 'accept-charset',
31
+ defaultMuted: 'muted',
32
+ defaultSelected: 'selected',
33
+ encoding: 'enctype',
31
34
  htmlFor: 'for',
32
35
  httpEquiv: 'http-equiv',
33
36
  } as const;
@@ -1,3 +1,4 @@
1
+ export * from './after-attach.mjs';
1
2
  export * from './as-constant.mjs';
2
3
  export * from './bind-value.mjs';
3
4
  export * from './dom-tables.mjs';
@@ -118,7 +118,20 @@ type IsWritable<E, K extends keyof E> =
118
118
 
119
119
  /**
120
120
  * Writable primitive properties that are not attributes, or that are props of
121
- * their own (`class`, `style`).
121
+ * their own (`class`, `style`). The binding would pass them to `setAttribute`,
122
+ * which makes an attribute nothing reads (#2934): the parts of `<a>`'s `href`
123
+ * (`hash` … `username`), the text of `<a>`, `<option>`, `<script>` and
124
+ * `<title>` (`text`), media playback state (`currentTime` … `volume`), and form
125
+ * control state (`returnValue` … `length`), and the obsolete `ch` and `chOff`
126
+ * of table cells, whose attributes are `char` and `charoff`. A property written as a property is
127
+ * in `propertyNames`, and one whose attribute is spelt differently in
128
+ * `attributeNameOf`. Checked in Chromium by setting the attribute and reading
129
+ * the property back.
130
+ *
131
+ * The keys are left out of every element, so a name that is an attribute on one
132
+ * element and not on another goes by the common case: `<body>`'s obsolete
133
+ * `text` attribute is left out with the rest, and `<style>`'s `disabled`, which
134
+ * has no attribute, stays in with every other `disabled`.
122
135
  */
123
136
  type ExcludedKey =
124
137
  | 'className'
@@ -129,4 +142,28 @@ type ExcludedKey =
129
142
  | 'outerText'
130
143
  | 'scrollLeft'
131
144
  | 'scrollTop'
132
- | 'textContent';
145
+ | 'textContent'
146
+ | 'hash'
147
+ | 'host'
148
+ | 'hostname'
149
+ | 'password'
150
+ | 'pathname'
151
+ | 'port'
152
+ | 'protocol'
153
+ | 'search'
154
+ | 'username'
155
+ | 'text'
156
+ | 'currentTime'
157
+ | 'defaultPlaybackRate'
158
+ | 'playbackRate'
159
+ | 'preservesPitch'
160
+ | 'volume'
161
+ | 'returnValue'
162
+ | 'selectedIndex'
163
+ | 'selectionDirection'
164
+ | 'selectionEnd'
165
+ | 'selectionStart'
166
+ | 'valueAsNumber'
167
+ | 'length'
168
+ | 'ch'
169
+ | 'chOff';
package/src/jsx/jsx.mts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { hasKey, isRecord } from 'ts-data-forge';
2
2
  import { bindProps } from '../bind/index.mjs';
3
- import { svgTagNames } from '../internal/index.mjs';
3
+ import { appendTo, mountThenInsert, svgTagNames } from '../internal/index.mjs';
4
4
  import { isChild, mountChild, type Child } from './child.mjs';
5
5
  import { mountable, type Mountable } from './mountable.mjs';
6
6
 
@@ -50,7 +50,7 @@ const mountElement = (tag: string, props: unknown): Element => {
50
50
 
51
51
  // Children first, so that a `<select>`'s `value` finds its `<option>`s.
52
52
  if (hasKey(props, 'children') && isChild(props.children)) {
53
- element.append(...mountChild(props.children));
53
+ mountThenInsert(mountChild, props.children, appendTo, element);
54
54
  }
55
55
 
56
56
  bindProps(element, props);
@@ -0,0 +1 @@
1
+ export * from './is-ime-key-down.mjs';
@@ -0,0 +1,20 @@
1
+ import type { StrictPick } from 'ts-type-forge';
2
+
3
+ /**
4
+ * Whether a key down is an input method's rather than the field's: one sent
5
+ * while a composition is going on, or the Enter or Escape that confirmed or
6
+ * cancelled one, which Safari sends after `compositionend`, told from the
7
+ * field's by its key code alone. Other keys with that key code are the field's:
8
+ * a virtual keyboard on Android sends it for the keys that type text, and its
9
+ * arrow keys may carry it too.
10
+ */
11
+ export const isImeKeyDown = (
12
+ event: StrictPick<KeyboardEvent, 'isComposing' | 'key' | 'keyCode'>,
13
+ ): boolean =>
14
+ event.isComposing ||
15
+ ((event.key === 'Enter' || event.key === 'Escape') &&
16
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- the key code is all that tells Safari's apart
17
+ event.keyCode === imeKeyCode);
18
+
19
+ /** The key code of a key down that an input method takes. */
20
+ const imeKeyCode = 229;