synstate-dom 0.4.0 → 0.6.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 (80) hide show
  1. package/README.md +5 -2
  2. package/dist/bind/bind-props.d.mts +4 -4
  3. package/dist/bind/bind-props.d.mts.map +1 -1
  4. package/dist/bind/bind-props.mjs +109 -39
  5. package/dist/bind/bind-props.mjs.map +1 -1
  6. package/dist/constant/constant.d.mts +2 -2
  7. package/dist/constant/derive.mjs.map +1 -1
  8. package/dist/constant/view.d.mts +3 -3
  9. package/dist/control/for.d.mts +2 -2
  10. package/dist/control/for.mjs.map +1 -1
  11. package/dist/control/if.d.mts +2 -2
  12. package/dist/control/longest-increasing-subsequence.d.mts +3 -3
  13. package/dist/control/match-optional.d.mts +4 -4
  14. package/dist/control/match-result.d.mts +5 -6
  15. package/dist/control/match-result.d.mts.map +1 -1
  16. package/dist/control/match-result.mjs +0 -1
  17. package/dist/control/match-result.mjs.map +1 -1
  18. package/dist/control/mount-switch.d.mts +5 -5
  19. package/dist/control/portal.d.mts +2 -2
  20. package/dist/index.d.mts +2 -0
  21. package/dist/index.d.mts.map +1 -1
  22. package/dist/index.mjs +2 -0
  23. package/dist/index.mjs.map +1 -1
  24. package/dist/internal/as-constant.d.mts +4 -4
  25. package/dist/internal/bind-value.d.mts.map +1 -1
  26. package/dist/internal/bind-value.mjs +7 -8
  27. package/dist/internal/bind-value.mjs.map +1 -1
  28. package/dist/internal/dom-tables.d.mts.map +1 -1
  29. package/dist/internal/dom-tables.mjs +3 -0
  30. package/dist/internal/dom-tables.mjs.map +1 -1
  31. package/dist/jsx/child.d.mts +2 -2
  32. package/dist/jsx/element-props.d.mts +17 -4
  33. package/dist/jsx/element-props.d.mts.map +1 -1
  34. package/dist/jsx/jsx.mjs +1 -1
  35. package/dist/jsx/jsx.mjs.map +1 -1
  36. package/dist/jsx/mountable.d.mts +4 -4
  37. package/dist/jsx-dev-runtime.d.mts +2 -2
  38. package/dist/keyboard/index.d.mts +2 -0
  39. package/dist/keyboard/index.d.mts.map +1 -0
  40. package/dist/keyboard/index.mjs +2 -0
  41. package/dist/keyboard/index.mjs.map +1 -0
  42. package/dist/keyboard/is-ime-key-down.d.mts +11 -0
  43. package/dist/keyboard/is-ime-key-down.d.mts.map +1 -0
  44. package/dist/keyboard/is-ime-key-down.mjs +15 -0
  45. package/dist/keyboard/is-ime-key-down.mjs.map +1 -0
  46. package/dist/scope/index.d.mts +2 -0
  47. package/dist/scope/index.d.mts.map +1 -0
  48. package/dist/scope/index.mjs +2 -0
  49. package/dist/scope/index.mjs.map +1 -0
  50. package/dist/scope/scope-effects.d.mts +25 -0
  51. package/dist/scope/scope-effects.d.mts.map +1 -0
  52. package/dist/scope/scope-effects.mjs +43 -0
  53. package/dist/scope/scope-effects.mjs.map +1 -0
  54. package/package.json +9 -9
  55. package/src/bind/bind-props.mts +169 -66
  56. package/src/constant/constant.mts +2 -2
  57. package/src/constant/derive.mts +4 -4
  58. package/src/constant/view.mts +3 -3
  59. package/src/control/for.mts +10 -10
  60. package/src/control/if.mts +2 -2
  61. package/src/control/longest-increasing-subsequence.mts +3 -3
  62. package/src/control/match-optional.mts +4 -4
  63. package/src/control/match-result.mts +5 -6
  64. package/src/control/mount-switch.mts +5 -5
  65. package/src/control/portal.mts +2 -2
  66. package/src/index.mts +2 -0
  67. package/src/internal/as-constant.mts +4 -4
  68. package/src/internal/bind-value.mts +7 -13
  69. package/src/internal/dom-tables.mts +3 -0
  70. package/src/jsx/child.mts +2 -2
  71. package/src/jsx/element-props.mts +41 -4
  72. package/src/jsx/jsx.mts +5 -5
  73. package/src/jsx/mountable.mts +4 -4
  74. package/src/jsx-dev-runtime.mts +2 -2
  75. package/src/keyboard/index.mts +1 -0
  76. package/src/keyboard/is-ime-key-down.mts +20 -0
  77. package/src/keyboard/is-ime-key-down.test.mts +42 -0
  78. package/src/scope/index.mts +1 -0
  79. package/src/scope/scope-effects.mts +60 -0
  80. package/src/scope/scope-effects.test.mts +89 -0
@@ -31,8 +31,8 @@ import { longestIncreasingSubsequence } from './longest-increasing-subsequence.m
31
31
  * nodes; the rows that stay are moved as little as the longest increasing
32
32
  * subsequence allows.
33
33
  *
34
- * `key` is required, as in React; `(_, i) => i` matches rows by position. A
35
- * key that appears twice is reported with `console.error`, and its later
34
+ * `key` is required, as in React; `(_, i) => i` matches rows by position. A key
35
+ * that appears twice is reported with `console.error`, and its later
36
36
  * occurrences are rebuilt on every update.
37
37
  */
38
38
  export const For = <T,>({
@@ -57,8 +57,8 @@ export const For = <T,>({
57
57
  );
58
58
 
59
59
  // `index$` is a node of its own in every row, visited on every update of
60
- // `items`; a `children` that declares no second parameter is not given
61
- // one, as Solid's `For` does.
60
+ // `items`; a `children` that declares no second parameter is not given one,
61
+ // as Solid's `For` does.
62
62
  const usesIndex = children.length >= 2;
63
63
 
64
64
  const callChildren: BivariantHack<
@@ -132,8 +132,8 @@ export const For = <T,>({
132
132
  /**
133
133
  * When no key stays, takes every row's nodes out in one range, rather than
134
134
  * one by one as each row's scope is disposed: clearing or replacing the
135
- * whole list is then one DOM operation. The scopes are disposed after,
136
- * and find their nodes already detached.
135
+ * whole list is then one DOM operation. The scopes are disposed after, and
136
+ * find their nodes already detached.
137
137
  */
138
138
  const removeAllAtOnceIfEveryKeyGoes = (
139
139
  previousOrder: readonly unknown[],
@@ -295,12 +295,12 @@ const toEntries = <T,>(
295
295
  };
296
296
 
297
297
  /**
298
- * One field of a row's entry, as a single child of `entries$`, where
299
- * `map`, `filter`, `withInitialValue` and `skipIfNoChange` would make four:
298
+ * One field of a row's entry, as a single child of `entries$`, where `map`,
299
+ * `filter`, `withInitialValue` and `skipIfNoChange` would make four:
300
300
  * creating and disposing a row costs roughly its number of nodes.
301
301
  *
302
- * A row whose key has gone keeps its last value; its scope is disposed in
303
- * the same update.
302
+ * A row whose key has gone keeps its last value; its scope is disposed in the
303
+ * same update.
304
304
  */
305
305
  const rowField = <T, A>(
306
306
  entries$: Observable<Entries<T>>,
@@ -3,8 +3,8 @@ import { mountable, type JSX } from '../jsx/index.mjs';
3
3
  import { mountSwitch } from './mount-switch.mjs';
4
4
 
5
5
  /**
6
- * Mounts `then` while `cond` is `true` and `else` while it is `false`
7
- * (SPEC.md 8.2). Nothing is mounted for an omitted `else`.
6
+ * Mounts `then` while `cond` is `true` and `else` while it is `false` (SPEC.md
7
+ * 8.2). Nothing is mounted for an omitted `else`.
8
8
  *
9
9
  * Both branches are descriptions, so only the one chosen is mounted. A branch
10
10
  * is rebuilt only when `cond` flips; flipping disposes the previous branch's
@@ -1,7 +1,7 @@
1
1
  /**
2
- * The positions in `values` of one longest strictly increasing subsequence,
3
- * in O(n log n). `For` keeps the rows at these positions in place and moves
4
- * only the others (SPEC.md 8.4).
2
+ * The positions in `values` of one longest strictly increasing subsequence, in
3
+ * O(n log n). `For` keeps the rows at these positions in place and moves only
4
+ * the others (SPEC.md 8.4).
5
5
  */
6
6
  export const longestIncreasingSubsequence = (
7
7
  values: readonly number[],
@@ -13,10 +13,10 @@ import { mountSwitch } from './mount-switch.mjs';
13
13
  * Mounts `some` while `value` holds a value and `none` while it is empty
14
14
  * (SPEC.md 8.3).
15
15
  *
16
- * `some` is called once per change from empty to present, with an observable
17
- * of the value narrowed to `T`. While the value stays present, new values flow
18
- * through that observable rather than rebuilding the branch. It is derived
19
- * from `value`, not written to, so nothing is written during propagation.
16
+ * `some` is called once per change from empty to present, with an observable of
17
+ * the value narrowed to `T`. While the value stays present, new values flow
18
+ * through that observable rather than rebuilding the branch. It is derived from
19
+ * `value`, not written to, so nothing is written during propagation.
20
20
  */
21
21
  export const MatchOptional = <T,>({
22
22
  value,
@@ -10,13 +10,12 @@ import { mountable, type JSX } from '../jsx/index.mjs';
10
10
  import { mountSwitch } from './mount-switch.mjs';
11
11
 
12
12
  /**
13
- * Mounts `ok` while `value` is `Ok` and `err` while it is `Err`
14
- * (SPEC.md 8.3).
13
+ * Mounts `ok` while `value` is `Ok` and `err` while it is `Err` (SPEC.md 8.3).
15
14
  *
16
- * Each branch is called once per change of tag, with an observable of the
17
- * value narrowed to `S` or `E`; while the tag stays the same, new values flow
18
- * through that observable rather than rebuilding the branch. It is derived
19
- * from `value`, not written to, so nothing is written during propagation.
15
+ * Each branch is called once per change of tag, with an observable of the value
16
+ * narrowed to `S` or `E`; while the tag stays the same, new values flow through
17
+ * that observable rather than rebuilding the branch. It is derived from
18
+ * `value`, not written to, so nothing is written during propagation.
20
19
  */
21
20
  export const MatchResult = <S, E>({
22
21
  value,
@@ -14,11 +14,11 @@ import { toNodes, type Mountable, type MountedNodes } from '../jsx/index.mjs';
14
14
  * the value of `value` (SPEC.md 8.1–8.3).
15
15
  *
16
16
  * The branch is rebuilt only when `keyOf` gives a different key: within the
17
- * same key the branch keeps its nodes and its scope, and it is up to the
18
- * branch to follow the value (`MatchOptional` hands it an observable derived
19
- * from `value`). Each branch is mounted in a scope of its own, a child of the
20
- * scope the switch was mounted in; disposing the branch's scope removes its
21
- * nodes, so the switch's own nodes stay accurate however often it switches.
17
+ * same key the branch keeps its nodes and its scope, and it is up to the branch
18
+ * to follow the value (`MatchOptional` hands it an observable derived from
19
+ * `value`). Each branch is mounted in a scope of its own, a child of the scope
20
+ * the switch was mounted in; disposing the branch's scope removes its nodes, so
21
+ * the switch's own nodes stay accurate however often it switches.
22
22
  *
23
23
  * `branch` runs inside the new scope, so whatever it creates belongs to the
24
24
  * branch.
@@ -3,8 +3,8 @@ import { mountChild, mountable, type Child, type JSX } from '../jsx/index.mjs';
3
3
 
4
4
  /**
5
5
  * Mounts `children` into `target` (`document.body` when omitted) rather than
6
- * where it is written (SPEC.md 8.6): for a modal, a tooltip or a toast that
7
- * has to escape an ancestor's `overflow: hidden` or `z-index`.
6
+ * where it is written (SPEC.md 8.6): for a modal, a tooltip or a toast that has
7
+ * to escape an ancestor's `overflow: hidden` or `z-index`.
8
8
  *
9
9
  * The children belong to the scope where `Portal` is written, so closing the
10
10
  * enclosing `If` branch removes them too. Nothing is left in the place it is
package/src/index.mts CHANGED
@@ -1,4 +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';
6
+ export * from './scope/index.mjs';
@@ -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
@@ -1,9 +1,6 @@
1
- import {
2
- isObservable,
3
- onScopeDispose,
4
- type InitializedObservable,
5
- } from 'synstate';
1
+ import { isObservable, type InitializedObservable } from 'synstate';
6
2
  import type { Bindable } from '../constant/index.mjs';
3
+ import { subscribeInScope } from '../scope/index.mjs';
7
4
  import { isView } from './is-view.mjs';
8
5
 
9
6
  /**
@@ -23,7 +20,7 @@ export const bindValue = <T,>(
23
20
  ): void => {
24
21
  if (isView(bindable)) {
25
22
  bindable.open((source, fn) => {
26
- subscribeInScope(source, (value) => {
23
+ bindObservable(source, (value) => {
27
24
  write(fn(value));
28
25
  });
29
26
  });
@@ -32,7 +29,7 @@ export const bindValue = <T,>(
32
29
  }
33
30
 
34
31
  if (isObservable(bindable)) {
35
- subscribeInScope(bindable, write);
32
+ bindObservable(bindable, write);
36
33
 
37
34
  return;
38
35
  }
@@ -40,7 +37,8 @@ export const bindValue = <T,>(
40
37
  write(bindable);
41
38
  };
42
39
 
43
- const subscribeInScope = <A,>(
40
+ /** A completed observable is written once and not subscribed to. */
41
+ const bindObservable = <A,>(
44
42
  source: InitializedObservable<A>,
45
43
  write: (value: A) => void,
46
44
  ): void => {
@@ -50,9 +48,5 @@ const subscribeInScope = <A,>(
50
48
  return;
51
49
  }
52
50
 
53
- const subscription = source.subscribe(write);
54
-
55
- onScopeDispose(() => {
56
- subscription.unsubscribe();
57
- });
51
+ subscribeInScope(source, write);
58
52
  };
@@ -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;
package/src/jsx/child.mts CHANGED
@@ -37,8 +37,8 @@ export const toNodes = (nodes: MountedNodes): readonly ChildNode[] =>
37
37
  Arr.isArray(nodes) ? nodes : ([nodes] as const);
38
38
 
39
39
  /**
40
- * Tells a child from any other value, one level deep: the elements of an
41
- * array are checked as they are mounted.
40
+ * Tells a child from any other value, one level deep: the elements of an array
41
+ * are checked as they are mounted.
42
42
  */
43
43
  export const isChild = (value: unknown): value is Child =>
44
44
  Arr.isArray(value) ||
@@ -43,8 +43,8 @@ export type StyleObject = ReadonlyRecord<string, Bindable<string | undefined>>;
43
43
 
44
44
  /**
45
45
  * The props of an HTML element: the common ones, one `Bindable` per writable
46
- * primitive DOM property of `E` (`id`, `disabled`, `value`, `tabIndex`, …),
47
- * and one handler per event of `HTMLElementEventMap`.
46
+ * primitive DOM property of `E` (`id`, `disabled`, `value`, `tabIndex`, …), and
47
+ * one handler per event of `HTMLElementEventMap`.
48
48
  *
49
49
  * An attribute, `aria-*` and `data-*` included, also takes `undefined`, which
50
50
  * removes it: a value that comes and goes is an observable of `T | undefined`
@@ -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
@@ -19,8 +19,8 @@ export const createMountable = <P,>(
19
19
  props: P,
20
20
  key?: unknown,
21
21
  ): Mountable => {
22
- // The transform takes `key` out of the props and passes it on its own. It
23
- // is put back for a component: `For` takes its `key` function as a prop.
22
+ // The transform takes `key` out of the props and passes it on its own. It is
23
+ // put back for a component: `For` takes its `key` function as a prop.
24
24
  const propsWithKey = key === undefined ? props : ({ ...props, key } as const);
25
25
 
26
26
  return typeof type === 'string'
@@ -32,6 +32,9 @@ export const createMountable = <P,>(
32
32
  export const Fragment = (props: Readonly<{ children?: Child }>): Mountable =>
33
33
  mountable(() => mountChild(props.children ?? []));
34
34
 
35
+ const isRefCallback = (value: unknown): value is (element: Element) => void =>
36
+ typeof value === 'function';
37
+
35
38
  const mountElement = (tag: string, props: unknown): Element => {
36
39
  const element = svgTagNameSet.has(tag)
37
40
  ? document.createElementNS(svgNamespace, tag)
@@ -58,6 +61,3 @@ const mountElement = (tag: string, props: unknown): Element => {
58
61
  const svgNamespace = 'http://www.w3.org/2000/svg';
59
62
 
60
63
  const svgTagNameSet: ReadonlySet<string> = new Set(svgTagNames);
61
-
62
- const isRefCallback = (value: unknown): value is (element: Element) => void =>
63
- typeof value === 'function';
@@ -1,8 +1,8 @@
1
1
  import { mountableTag } from '../internal/index.mjs';
2
2
 
3
3
  /**
4
- * What a JSX expression evaluates to: a description that creates DOM nodes
5
- * when mounted, not the nodes themselves (SPEC.md 6.1).
4
+ * What a JSX expression evaluates to: a description that creates DOM nodes when
5
+ * mounted, not the nodes themselves (SPEC.md 6.1).
6
6
  *
7
7
  * The children of a JSX element are evaluated before the element, so a
8
8
  * description that created its nodes at once would create both branches of
@@ -11,8 +11,8 @@ import { mountableTag } from '../internal/index.mjs';
11
11
  * It is an object rather than a function: a props object with a `then` prop
12
12
  * holding a function would be taken for a thenable.
13
13
  *
14
- * The same description may be mounted any number of times; each mount
15
- * creates new nodes.
14
+ * The same description may be mounted any number of times; each mount creates
15
+ * new nodes.
16
16
  */
17
17
  export type Mountable = Readonly<{
18
18
  [mountableTag]: true;
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The module `"jsxImportSource": "synstate-dom"` makes TypeScript import for
3
- * `"jsx": "react-jsxdev"`. The extra arguments (`key`, source location) are
4
- * not used.
3
+ * `"jsx": "react-jsxdev"`. The extra arguments (`key`, source location) are not
4
+ * used.
5
5
  */
6
6
  export { Fragment, createMountable as jsxDEV, type JSX } from './jsx/index.mjs';
@@ -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;
@@ -0,0 +1,42 @@
1
+ import { isImeKeyDown } from './is-ime-key-down.mjs';
2
+
3
+ // Plain objects stand in for `KeyboardEvent`s: it reads only these fields, and
4
+ // Node.js has no `KeyboardEvent`.
5
+ describe(isImeKeyDown, () => {
6
+ test('is a key down sent while an input method is composing', () => {
7
+ assert.isTrue(isImeKeyDown(keyDown('a', 229, true)));
8
+
9
+ assert.isTrue(isImeKeyDown(keyDown('ArrowUp', 229, true)));
10
+ });
11
+
12
+ test.each(['Enter', 'Escape'])(
13
+ 'is %s with key code 229, which Safari sends after compositionend',
14
+ (key) => {
15
+ assert.isTrue(isImeKeyDown(keyDown(key, 229)));
16
+ },
17
+ );
18
+
19
+ test('is not another key with key code 229, as an Android virtual keyboard sends every key', () => {
20
+ assert.isFalse(isImeKeyDown(keyDown('ArrowUp', 229)));
21
+ });
22
+
23
+ test.each([
24
+ ['Enter', 13],
25
+ ['Escape', 27],
26
+ ['a', 65],
27
+ ] as const)('is not a plain %s', (key, code) => {
28
+ assert.isFalse(isImeKeyDown(keyDown(key, code)));
29
+ });
30
+ });
31
+
32
+ /** The fields of a key down that `isImeKeyDown` reads. */
33
+ const keyDown = (
34
+ key: string,
35
+ code: number,
36
+ isComposing: boolean = false,
37
+ ): Readonly<{ key: string; keyCode: number; isComposing: boolean }> =>
38
+ ({
39
+ key,
40
+ keyCode: code,
41
+ isComposing,
42
+ }) as const;
@@ -0,0 +1 @@
1
+ export * from './scope-effects.mjs';
@@ -0,0 +1,60 @@
1
+ import { onScopeDispose, type Observable } from 'synstate';
2
+ import type { StrictPick } from 'ts-type-forge';
3
+
4
+ /**
5
+ * Calls `next` with every value of `source`, the current one first, until the
6
+ * current scope is disposed — what a component that is called once does in
7
+ * place of an effect.
8
+ */
9
+ export const subscribeInScope = <A,>(
10
+ source: Observable<A>,
11
+ next: (value: A) => void,
12
+ ): void => {
13
+ const subscription = source.subscribe(next);
14
+
15
+ onScopeDispose(() => {
16
+ subscription.unsubscribe();
17
+ });
18
+ };
19
+
20
+ /** Listens to `type` on `target` until the current scope is disposed. */
21
+ export const listenInScope = (
22
+ target: Readonly<
23
+ StrictPick<EventTarget, 'addEventListener' | 'removeEventListener'>
24
+ >,
25
+ type: string,
26
+ listener: (event: Event) => void,
27
+ options?: Readonly<{ capture?: boolean; passive?: boolean }>,
28
+ ): void => {
29
+ target.addEventListener(type, listener, options);
30
+
31
+ onScopeDispose(() => {
32
+ target.removeEventListener(type, listener, options);
33
+ });
34
+ };
35
+
36
+ /**
37
+ * Makes the function that runs work once the update under way has finished:
38
+ * in a microtask, so before the next paint, with the DOM bound to the update
39
+ * already written — and nothing written to a state while another is still
40
+ * propagating, which synstate warns of. Work for a scope disposed by then is
41
+ * dropped.
42
+ *
43
+ * Also what runs after the first mount: a component's nodes are attached to the
44
+ * document only once the whole description they are in is mounted.
45
+ */
46
+ export const createAfterUpdate = (): ((work: () => void) => void) => {
47
+ let mut_alive = true;
48
+
49
+ onScopeDispose(() => {
50
+ mut_alive = false;
51
+ });
52
+
53
+ return (work) => {
54
+ queueMicrotask(() => {
55
+ if (mut_alive) {
56
+ work();
57
+ }
58
+ });
59
+ };
60
+ };
@@ -0,0 +1,89 @@
1
+ import { createScope, createState } from 'synstate';
2
+ import {
3
+ createAfterUpdate,
4
+ listenInScope,
5
+ subscribeInScope,
6
+ } from './scope-effects.mjs';
7
+
8
+ describe(subscribeInScope, () => {
9
+ test('follows the source, the current value first, until the scope is disposed', () => {
10
+ const scope = createScope(undefined);
11
+
12
+ const [source, setSource] = createState(0);
13
+
14
+ const mut_received: number[] = [];
15
+
16
+ scope.run(() => {
17
+ subscribeInScope(source, (value) => {
18
+ mut_received.push(value);
19
+ });
20
+ });
21
+
22
+ setSource(1);
23
+
24
+ scope.dispose();
25
+
26
+ setSource(2);
27
+
28
+ assert.deepStrictEqual(mut_received, [0, 1]);
29
+ });
30
+ });
31
+
32
+ describe(createAfterUpdate, () => {
33
+ test('runs the work in a microtask, and drops it once the scope is disposed', async () => {
34
+ const scope = createScope(undefined);
35
+
36
+ const afterUpdate = scope.run(createAfterUpdate);
37
+
38
+ const mut_log: string[] = [];
39
+
40
+ afterUpdate(() => {
41
+ mut_log.push('first');
42
+ });
43
+
44
+ assert.deepStrictEqual(mut_log, []);
45
+
46
+ await Promise.resolve();
47
+
48
+ assert.deepStrictEqual(mut_log, ['first']);
49
+
50
+ afterUpdate(() => {
51
+ mut_log.push('second');
52
+ });
53
+
54
+ scope.dispose();
55
+
56
+ await Promise.resolve();
57
+
58
+ assert.deepStrictEqual(mut_log, ['first']);
59
+ });
60
+ });
61
+
62
+ describe(listenInScope, () => {
63
+ test('listens, with the options it is given, until the scope is disposed', () => {
64
+ const scope = createScope(undefined);
65
+
66
+ const target = new EventTarget();
67
+
68
+ const mut_received: string[] = [];
69
+
70
+ scope.run(() => {
71
+ listenInScope(
72
+ target,
73
+ 'ping',
74
+ (event) => {
75
+ mut_received.push(event.type);
76
+ },
77
+ { capture: true },
78
+ );
79
+ });
80
+
81
+ target.dispatchEvent(new Event('ping'));
82
+
83
+ scope.dispose();
84
+
85
+ target.dispatchEvent(new Event('ping'));
86
+
87
+ assert.deepStrictEqual(mut_received, ['ping']);
88
+ });
89
+ });