@opentf/web 0.3.0 → 0.5.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 (179) hide show
  1. package/components/Link.jsx +29 -0
  2. package/core/errors.js +48 -0
  3. package/core/signals.js +188 -0
  4. package/index.js +12 -0
  5. package/package.json +27 -18
  6. package/runtime/context.js +89 -0
  7. package/runtime/dom.js +280 -0
  8. package/runtime/error-boundary.js +109 -0
  9. package/runtime/events.js +17 -0
  10. package/runtime/index.js +16 -0
  11. package/runtime/lifecycle.js +9 -0
  12. package/runtime/mount.js +49 -0
  13. package/runtime/portal.js +59 -0
  14. package/runtime/router.js +246 -0
  15. package/server/builtins.js +11 -0
  16. package/server/index.js +7 -0
  17. package/server/render.js +76 -0
  18. package/server/ssg-runtime.js +109 -0
  19. package/dist/CHANGELOG.md +0 -24
  20. package/dist/babel.config.cjs +0 -6
  21. package/dist/compiler/babel-plugin.cjs +0 -731
  22. package/dist/core/mount.js +0 -32
  23. package/dist/index.js +0 -7
  24. package/dist/node_modules/@babel/core/LICENSE +0 -22
  25. package/dist/node_modules/@babel/core/README.md +0 -19
  26. package/dist/node_modules/@babel/core/lib/config/cache-contexts.js +0 -5
  27. package/dist/node_modules/@babel/core/lib/config/cache-contexts.js.map +0 -1
  28. package/dist/node_modules/@babel/core/lib/config/caching.js +0 -261
  29. package/dist/node_modules/@babel/core/lib/config/caching.js.map +0 -1
  30. package/dist/node_modules/@babel/core/lib/config/config-chain.js +0 -469
  31. package/dist/node_modules/@babel/core/lib/config/config-chain.js.map +0 -1
  32. package/dist/node_modules/@babel/core/lib/config/config-descriptors.js +0 -190
  33. package/dist/node_modules/@babel/core/lib/config/config-descriptors.js.map +0 -1
  34. package/dist/node_modules/@babel/core/lib/config/files/configuration.js +0 -290
  35. package/dist/node_modules/@babel/core/lib/config/files/configuration.js.map +0 -1
  36. package/dist/node_modules/@babel/core/lib/config/files/import.cjs +0 -6
  37. package/dist/node_modules/@babel/core/lib/config/files/import.cjs.map +0 -1
  38. package/dist/node_modules/@babel/core/lib/config/files/index-browser.js +0 -58
  39. package/dist/node_modules/@babel/core/lib/config/files/index-browser.js.map +0 -1
  40. package/dist/node_modules/@babel/core/lib/config/files/index.js +0 -78
  41. package/dist/node_modules/@babel/core/lib/config/files/index.js.map +0 -1
  42. package/dist/node_modules/@babel/core/lib/config/files/module-types.js +0 -203
  43. package/dist/node_modules/@babel/core/lib/config/files/module-types.js.map +0 -1
  44. package/dist/node_modules/@babel/core/lib/config/files/package.js +0 -61
  45. package/dist/node_modules/@babel/core/lib/config/files/package.js.map +0 -1
  46. package/dist/node_modules/@babel/core/lib/config/files/plugins.js +0 -220
  47. package/dist/node_modules/@babel/core/lib/config/files/plugins.js.map +0 -1
  48. package/dist/node_modules/@babel/core/lib/config/files/types.js +0 -5
  49. package/dist/node_modules/@babel/core/lib/config/files/types.js.map +0 -1
  50. package/dist/node_modules/@babel/core/lib/config/files/utils.js +0 -36
  51. package/dist/node_modules/@babel/core/lib/config/files/utils.js.map +0 -1
  52. package/dist/node_modules/@babel/core/lib/config/full.js +0 -312
  53. package/dist/node_modules/@babel/core/lib/config/full.js.map +0 -1
  54. package/dist/node_modules/@babel/core/lib/config/helpers/config-api.js +0 -85
  55. package/dist/node_modules/@babel/core/lib/config/helpers/config-api.js.map +0 -1
  56. package/dist/node_modules/@babel/core/lib/config/helpers/deep-array.js +0 -23
  57. package/dist/node_modules/@babel/core/lib/config/helpers/deep-array.js.map +0 -1
  58. package/dist/node_modules/@babel/core/lib/config/helpers/environment.js +0 -12
  59. package/dist/node_modules/@babel/core/lib/config/helpers/environment.js.map +0 -1
  60. package/dist/node_modules/@babel/core/lib/config/index.js +0 -87
  61. package/dist/node_modules/@babel/core/lib/config/index.js.map +0 -1
  62. package/dist/node_modules/@babel/core/lib/config/item.js +0 -67
  63. package/dist/node_modules/@babel/core/lib/config/item.js.map +0 -1
  64. package/dist/node_modules/@babel/core/lib/config/partial.js +0 -158
  65. package/dist/node_modules/@babel/core/lib/config/partial.js.map +0 -1
  66. package/dist/node_modules/@babel/core/lib/config/pattern-to-regex.js +0 -38
  67. package/dist/node_modules/@babel/core/lib/config/pattern-to-regex.js.map +0 -1
  68. package/dist/node_modules/@babel/core/lib/config/plugin.js +0 -33
  69. package/dist/node_modules/@babel/core/lib/config/plugin.js.map +0 -1
  70. package/dist/node_modules/@babel/core/lib/config/printer.js +0 -113
  71. package/dist/node_modules/@babel/core/lib/config/printer.js.map +0 -1
  72. package/dist/node_modules/@babel/core/lib/config/resolve-targets-browser.js +0 -41
  73. package/dist/node_modules/@babel/core/lib/config/resolve-targets-browser.js.map +0 -1
  74. package/dist/node_modules/@babel/core/lib/config/resolve-targets.js +0 -61
  75. package/dist/node_modules/@babel/core/lib/config/resolve-targets.js.map +0 -1
  76. package/dist/node_modules/@babel/core/lib/config/util.js +0 -31
  77. package/dist/node_modules/@babel/core/lib/config/util.js.map +0 -1
  78. package/dist/node_modules/@babel/core/lib/config/validation/option-assertions.js +0 -277
  79. package/dist/node_modules/@babel/core/lib/config/validation/option-assertions.js.map +0 -1
  80. package/dist/node_modules/@babel/core/lib/config/validation/options.js +0 -187
  81. package/dist/node_modules/@babel/core/lib/config/validation/options.js.map +0 -1
  82. package/dist/node_modules/@babel/core/lib/config/validation/plugins.js +0 -67
  83. package/dist/node_modules/@babel/core/lib/config/validation/plugins.js.map +0 -1
  84. package/dist/node_modules/@babel/core/lib/config/validation/removed.js +0 -68
  85. package/dist/node_modules/@babel/core/lib/config/validation/removed.js.map +0 -1
  86. package/dist/node_modules/@babel/core/lib/errors/config-error.js +0 -18
  87. package/dist/node_modules/@babel/core/lib/errors/config-error.js.map +0 -1
  88. package/dist/node_modules/@babel/core/lib/errors/rewrite-stack-trace.js +0 -98
  89. package/dist/node_modules/@babel/core/lib/errors/rewrite-stack-trace.js.map +0 -1
  90. package/dist/node_modules/@babel/core/lib/gensync-utils/async.js +0 -90
  91. package/dist/node_modules/@babel/core/lib/gensync-utils/async.js.map +0 -1
  92. package/dist/node_modules/@babel/core/lib/gensync-utils/fs.js +0 -31
  93. package/dist/node_modules/@babel/core/lib/gensync-utils/fs.js.map +0 -1
  94. package/dist/node_modules/@babel/core/lib/gensync-utils/functional.js +0 -58
  95. package/dist/node_modules/@babel/core/lib/gensync-utils/functional.js.map +0 -1
  96. package/dist/node_modules/@babel/core/lib/index.js +0 -230
  97. package/dist/node_modules/@babel/core/lib/index.js.map +0 -1
  98. package/dist/node_modules/@babel/core/lib/parse.js +0 -45
  99. package/dist/node_modules/@babel/core/lib/parse.js.map +0 -1
  100. package/dist/node_modules/@babel/core/lib/parser/index.js +0 -85
  101. package/dist/node_modules/@babel/core/lib/parser/index.js.map +0 -1
  102. package/dist/node_modules/@babel/core/lib/parser/util/missing-plugin-helper.js +0 -337
  103. package/dist/node_modules/@babel/core/lib/parser/util/missing-plugin-helper.js.map +0 -1
  104. package/dist/node_modules/@babel/core/lib/tools/build-external-helpers.js +0 -144
  105. package/dist/node_modules/@babel/core/lib/tools/build-external-helpers.js.map +0 -1
  106. package/dist/node_modules/@babel/core/lib/transform-ast.js +0 -48
  107. package/dist/node_modules/@babel/core/lib/transform-ast.js.map +0 -1
  108. package/dist/node_modules/@babel/core/lib/transform-file-browser.js +0 -23
  109. package/dist/node_modules/@babel/core/lib/transform-file-browser.js.map +0 -1
  110. package/dist/node_modules/@babel/core/lib/transform-file.js +0 -40
  111. package/dist/node_modules/@babel/core/lib/transform-file.js.map +0 -1
  112. package/dist/node_modules/@babel/core/lib/transform.js +0 -47
  113. package/dist/node_modules/@babel/core/lib/transform.js.map +0 -1
  114. package/dist/node_modules/@babel/core/lib/transformation/block-hoist-plugin.js +0 -84
  115. package/dist/node_modules/@babel/core/lib/transformation/block-hoist-plugin.js.map +0 -1
  116. package/dist/node_modules/@babel/core/lib/transformation/file/babel-7-helpers.cjs +0 -4
  117. package/dist/node_modules/@babel/core/lib/transformation/file/babel-7-helpers.cjs.map +0 -1
  118. package/dist/node_modules/@babel/core/lib/transformation/file/file.js +0 -204
  119. package/dist/node_modules/@babel/core/lib/transformation/file/file.js.map +0 -1
  120. package/dist/node_modules/@babel/core/lib/transformation/file/generate.js +0 -84
  121. package/dist/node_modules/@babel/core/lib/transformation/file/generate.js.map +0 -1
  122. package/dist/node_modules/@babel/core/lib/transformation/file/merge-map.js +0 -37
  123. package/dist/node_modules/@babel/core/lib/transformation/file/merge-map.js.map +0 -1
  124. package/dist/node_modules/@babel/core/lib/transformation/index.js +0 -90
  125. package/dist/node_modules/@babel/core/lib/transformation/index.js.map +0 -1
  126. package/dist/node_modules/@babel/core/lib/transformation/normalize-file.js +0 -127
  127. package/dist/node_modules/@babel/core/lib/transformation/normalize-file.js.map +0 -1
  128. package/dist/node_modules/@babel/core/lib/transformation/normalize-opts.js +0 -59
  129. package/dist/node_modules/@babel/core/lib/transformation/normalize-opts.js.map +0 -1
  130. package/dist/node_modules/@babel/core/lib/transformation/plugin-pass.js +0 -48
  131. package/dist/node_modules/@babel/core/lib/transformation/plugin-pass.js.map +0 -1
  132. package/dist/node_modules/@babel/core/lib/transformation/util/clone-deep.js +0 -54
  133. package/dist/node_modules/@babel/core/lib/transformation/util/clone-deep.js.map +0 -1
  134. package/dist/node_modules/@babel/core/lib/vendor/import-meta-resolve.js +0 -1042
  135. package/dist/node_modules/@babel/core/lib/vendor/import-meta-resolve.js.map +0 -1
  136. package/dist/node_modules/@babel/core/package.json +0 -82
  137. package/dist/node_modules/@babel/core/src/config/files/index-browser.ts +0 -115
  138. package/dist/node_modules/@babel/core/src/config/files/index.ts +0 -30
  139. package/dist/node_modules/@babel/core/src/config/resolve-targets-browser.ts +0 -42
  140. package/dist/node_modules/@babel/core/src/config/resolve-targets.ts +0 -54
  141. package/dist/node_modules/@babel/core/src/transform-file-browser.ts +0 -33
  142. package/dist/node_modules/@babel/core/src/transform-file.ts +0 -56
  143. package/dist/node_modules/@babel/helper-module-imports/LICENSE +0 -22
  144. package/dist/node_modules/@babel/helper-module-imports/README.md +0 -19
  145. package/dist/node_modules/@babel/helper-module-imports/lib/import-builder.js +0 -122
  146. package/dist/node_modules/@babel/helper-module-imports/lib/import-builder.js.map +0 -1
  147. package/dist/node_modules/@babel/helper-module-imports/lib/import-injector.js +0 -304
  148. package/dist/node_modules/@babel/helper-module-imports/lib/import-injector.js.map +0 -1
  149. package/dist/node_modules/@babel/helper-module-imports/lib/index.js +0 -37
  150. package/dist/node_modules/@babel/helper-module-imports/lib/index.js.map +0 -1
  151. package/dist/node_modules/@babel/helper-module-imports/lib/is-module.js +0 -11
  152. package/dist/node_modules/@babel/helper-module-imports/lib/is-module.js.map +0 -1
  153. package/dist/node_modules/@babel/helper-module-imports/package.json +0 -28
  154. package/dist/node_modules/@babel/plugin-syntax-jsx/LICENSE +0 -22
  155. package/dist/node_modules/@babel/plugin-syntax-jsx/README.md +0 -19
  156. package/dist/node_modules/@babel/plugin-syntax-jsx/lib/index.js +0 -21
  157. package/dist/node_modules/@babel/plugin-syntax-jsx/lib/index.js.map +0 -1
  158. package/dist/node_modules/@babel/plugin-syntax-jsx/package.json +0 -33
  159. package/dist/node_modules/@preact/signals-core/CHANGELOG.md +0 -331
  160. package/dist/node_modules/@preact/signals-core/LICENSE +0 -21
  161. package/dist/node_modules/@preact/signals-core/README.md +0 -258
  162. package/dist/node_modules/@preact/signals-core/dist/signals-core.d.ts +0 -149
  163. package/dist/node_modules/@preact/signals-core/dist/signals-core.js +0 -1
  164. package/dist/node_modules/@preact/signals-core/dist/signals-core.js.map +0 -1
  165. package/dist/node_modules/@preact/signals-core/dist/signals-core.min.js +0 -1
  166. package/dist/node_modules/@preact/signals-core/dist/signals-core.min.js.map +0 -1
  167. package/dist/node_modules/@preact/signals-core/dist/signals-core.mjs +0 -1
  168. package/dist/node_modules/@preact/signals-core/dist/signals-core.mjs.map +0 -1
  169. package/dist/node_modules/@preact/signals-core/dist/signals-core.module.js +0 -1
  170. package/dist/node_modules/@preact/signals-core/dist/signals-core.module.js.map +0 -1
  171. package/dist/node_modules/@preact/signals-core/package.json +0 -48
  172. package/dist/node_modules/@preact/signals-core/src/index.ts +0 -1121
  173. package/dist/package.json +0 -30
  174. package/dist/router/Link.js +0 -73
  175. package/dist/router/index.js +0 -216
  176. package/dist/runtime/For.js +0 -16
  177. package/dist/runtime/dom.js +0 -73
  178. package/dist/runtime/lifecycle.js +0 -25
  179. package/dist/runtime/props.js +0 -103
package/runtime/dom.js ADDED
@@ -0,0 +1,280 @@
1
+ // Zero-VDOM DOM operations. CSR codegen emits calls into these helpers; they
2
+ // also back runtime-driven updates (reactive text/attributes). No virtual DOM,
3
+ // no diffing — just direct, fine-grained DOM writes wired to signals.
4
+
5
+ import { effect, signal } from "../core/signals.js";
6
+
7
+ // Attribute names that must be assigned as JS properties (not setAttribute) for
8
+ // correct behavior. Mirrors the set the old runtime relied on (core/constants).
9
+ const AS_PROPERTY = new Set([
10
+ "value",
11
+ "checked",
12
+ "selected",
13
+ "disabled",
14
+ "readOnly",
15
+ "className",
16
+ "htmlFor",
17
+ "indeterminate",
18
+ ]);
19
+
20
+ // Enumerated attributes: their value is a keyword string ("true"/"false"), not a
21
+ // boolean-attribute presence toggle. `draggable={true}` must become
22
+ // draggable="true" — draggable="" reads back as the default (false). ARIA states
23
+ // behave the same (aria-*="true"/"false").
24
+ const ENUMERATED = new Set(["draggable", "spellcheck", "contenteditable"]);
25
+
26
+ // CSS properties whose numeric values are unitless; every other numeric value
27
+ // gets "px" appended (matching React/JSX conventions, so `style={{ width: 8 }}`
28
+ // means 8px). Mirrors React's CSSProperty unitless list.
29
+ const UNITLESS = new Set([
30
+ "animationIterationCount", "aspectRatio", "borderImageOutset", "borderImageSlice",
31
+ "borderImageWidth", "boxFlex", "boxFlexGroup", "boxOrdinalGroup", "columnCount",
32
+ "columns", "flex", "flexGrow", "flexShrink", "flexOrder", "gridArea", "gridRow",
33
+ "gridRowEnd", "gridRowStart", "gridColumn", "gridColumnEnd", "gridColumnStart",
34
+ "fontWeight", "lineClamp", "lineHeight", "opacity", "order", "orphans", "tabSize",
35
+ "widows", "zIndex", "zoom", "fillOpacity", "floodOpacity", "stopOpacity",
36
+ "strokeDasharray", "strokeDashoffset", "strokeMiterlimit", "strokeOpacity",
37
+ "strokeWidth",
38
+ ]);
39
+
40
+ /**
41
+ * Apply a `style` object to an element. Numbers get "px" appended unless the
42
+ * property is unitless; CSS custom properties (`--foo`) go through setProperty;
43
+ * nullish values clear the property. A string value is treated as raw cssText.
44
+ */
45
+ export function applyStyle(el, value) {
46
+ if (typeof value === "string") {
47
+ el.style.cssText = value;
48
+ return;
49
+ }
50
+ for (const key in value) {
51
+ const v = value[key];
52
+ if (key.startsWith("--")) {
53
+ el.style.setProperty(key, v == null ? "" : String(v));
54
+ } else if (v == null) {
55
+ el.style[key] = "";
56
+ } else {
57
+ el.style[key] = typeof v === "number" && !UNITLESS.has(key) ? `${v}px` : v;
58
+ }
59
+ }
60
+ }
61
+
62
+ /** Render a reactive value to its text form: null/undefined/false → "". */
63
+ export function toText(value) {
64
+ return value == null || value === false ? "" : String(value);
65
+ }
66
+
67
+ /**
68
+ * clsx-style class normalization. Falsy entries are skipped; strings/numbers are
69
+ * kept; arrays recurse; objects contribute the keys whose values are truthy. Lets
70
+ * JSX write `class={["btn", active && "on", { lg: size === "lg" }]}`.
71
+ */
72
+ export function clsx(value) {
73
+ if (!value) return "";
74
+ if (typeof value === "string") return value;
75
+ if (typeof value === "number") return String(value);
76
+ if (Array.isArray(value)) {
77
+ const parts = [];
78
+ for (const v of value) {
79
+ const s = clsx(v);
80
+ if (s) parts.push(s);
81
+ }
82
+ return parts.join(" ");
83
+ }
84
+ if (typeof value === "object") {
85
+ const parts = [];
86
+ for (const k in value) if (value[k]) parts.push(k);
87
+ return parts.join(" ");
88
+ }
89
+ return "";
90
+ }
91
+
92
+ /**
93
+ * Apply a single static prop/attribute. Handles `style` objects, `on*` event
94
+ * handlers, property-backed attributes, and boolean/nullish removal; everything
95
+ * else falls back to `setAttribute`.
96
+ */
97
+ export function setAttr(el, name, value) {
98
+ if (name === "style" && value && (typeof value === "object" || typeof value === "string")) {
99
+ applyStyle(el, value);
100
+ return;
101
+ }
102
+ if (name === "class" && value && typeof value === "object") {
103
+ const c = clsx(value); // array/object form → a normalized class string
104
+ if (c) el.setAttribute("class", c);
105
+ else el.removeAttribute("class");
106
+ return;
107
+ }
108
+ if (name.startsWith("on") && typeof value === "function") {
109
+ el[name.toLowerCase()] = value;
110
+ return;
111
+ }
112
+ if (AS_PROPERTY.has(name)) {
113
+ el[name] = value;
114
+ return;
115
+ }
116
+ if (ENUMERATED.has(name) || name.startsWith("aria-")) {
117
+ if (value == null) el.removeAttribute(name);
118
+ else el.setAttribute(name, value === true ? "true" : value === false ? "false" : String(value));
119
+ return;
120
+ }
121
+ if (value == null || value === false) {
122
+ el.removeAttribute(name);
123
+ return;
124
+ }
125
+ if (value === true) {
126
+ el.setAttribute(name, "");
127
+ return;
128
+ }
129
+ el.setAttribute(name, String(value));
130
+ }
131
+
132
+ /**
133
+ * Apply a named prop to a *child component* element. Set it as a property when the
134
+ * element exposes one (custom-element prop setters, `value`, `checked`, …) so rich
135
+ * values (objects, signals) reach the component's setter; otherwise fall back to an
136
+ * attribute so plain ones (`class`, `data-*`, `aria-*`) still land correctly. This
137
+ * mirrors how JSX libraries target custom elements. If the element is not yet
138
+ * upgraded `name in el` is false, so the value lands as an attribute and the
139
+ * component picks it up from `getAttribute` when it upgrades.
140
+ */
141
+ export function setProp(el, name, value) {
142
+ if (name in el) el[name] = value;
143
+ else setAttr(el, name, value);
144
+ }
145
+
146
+ /**
147
+ * Apply every own enumerable key of `obj` to `el` (`<div {...obj}/>`, SPEC §5.5).
148
+ * `asProps` sets element properties (for components) instead of attributes (for
149
+ * host elements). Keys are applied in object order; the caller wraps this in an
150
+ * effect so a reactive source re-applies.
151
+ */
152
+ export function spread(el, obj, asProps) {
153
+ if (!obj) return;
154
+ for (const k in obj) {
155
+ if (asProps) el[k] = obj[k];
156
+ else setAttr(el, k, obj[k]);
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Wire a text node to a reactive expression. Primitive values update the text in
162
+ * place; a DOM node (or array of them) — e.g. JSX stored as a value, `{iconMap[k]}`
163
+ * — is inserted before the text node, which stays as a stable anchor. Returns the
164
+ * effect disposer so the caller (component lifecycle) can stop updates.
165
+ */
166
+ export function bindText(node, fn) {
167
+ let inserted = [];
168
+ return effect(() => {
169
+ const value = fn();
170
+ for (const n of inserted) {
171
+ if (n.parentNode) n.parentNode.removeChild(n);
172
+ }
173
+ inserted = [];
174
+ if (value instanceof Node || Array.isArray(value)) {
175
+ node.data = "";
176
+ const parent = node.parentNode;
177
+ if (parent) {
178
+ for (const n of toNodes(value)) {
179
+ parent.insertBefore(n, node);
180
+ inserted.push(n);
181
+ }
182
+ }
183
+ } else {
184
+ node.data = toText(value);
185
+ }
186
+ });
187
+ }
188
+
189
+ /** Wire an element attribute/prop to a reactive expression. Returns the disposer. */
190
+ export function bindAttr(el, name, fn) {
191
+ return effect(() => setAttr(el, name, fn()));
192
+ }
193
+
194
+ /** Flatten a reactive child value into DOM nodes: nodes pass through, arrays
195
+ * recurse, nullish/booleans render nothing, primitives become text nodes. */
196
+ function toNodes(value) {
197
+ if (value == null || typeof value === "boolean") return [];
198
+ if (Array.isArray(value)) {
199
+ const out = [];
200
+ for (const v of value) for (const n of toNodes(v)) out.push(n);
201
+ return out;
202
+ }
203
+ if (value instanceof Node) return [value];
204
+ return [document.createTextNode(String(value))];
205
+ }
206
+
207
+ /**
208
+ * Wire a dynamic child region (conditional/element-valued holes like
209
+ * `{cond && <p/>}` or `{cond ? <a/> : <b/>}`) to a reactive expression. The
210
+ * region is delimited by `anchor` (a comment); on every change the previous
211
+ * nodes are replaced with the new ones, inserted just before the anchor.
212
+ * Returns the effect disposer.
213
+ */
214
+ export function bindChild(anchor, fn) {
215
+ let current = [];
216
+ return effect(() => {
217
+ const next = toNodes(fn());
218
+ const host = anchor.parentNode;
219
+ for (const n of current) {
220
+ if (n.parentNode === host) host.removeChild(n);
221
+ }
222
+ if (host) for (const n of next) host.insertBefore(n, anchor);
223
+ current = next;
224
+ });
225
+ }
226
+
227
+ /**
228
+ * Render a keyed list (`array.map(...)`, SPEC §5.4.4) into `parent`, reconciling
229
+ * by key on every change to `sourceFn`'s dependencies.
230
+ *
231
+ * - `sourceFn()` returns the current array (plain data).
232
+ * - `renderItem(itemSignal, index)` builds a node for a new item; the item's
233
+ * value is a signal so per-item bindings update fine-grained on data changes.
234
+ * - `keyFn(item, index)` returns a stable key; falls back to `index` when omitted.
235
+ *
236
+ * Returns the effect disposer (stops reconciliation; used by component cleanup).
237
+ */
238
+ export function bindList(parent, sourceFn, renderItem, keyFn) {
239
+ const anchor = document.createComment("");
240
+ parent.appendChild(anchor);
241
+ let cache = new Map(); // key -> { sig, node }
242
+
243
+ return effect(() => {
244
+ const data = sourceFn();
245
+ const items = Array.isArray(data) ? data : [];
246
+ const next = new Map();
247
+ const nodes = [];
248
+
249
+ for (let index = 0; index < items.length; index++) {
250
+ const item = items[index];
251
+ const key = keyFn ? keyFn(item, index) : index;
252
+ let entry = cache.get(key);
253
+ if (entry) {
254
+ cache.delete(key);
255
+ entry.sig.value = item; // fine-grained per-item update
256
+ } else {
257
+ const sig = signal(item);
258
+ entry = { sig, node: renderItem(sig, index) };
259
+ }
260
+ next.set(key, entry);
261
+ nodes.push(entry.node);
262
+ }
263
+
264
+ // Remove nodes whose keys disappeared.
265
+ for (const entry of cache.values()) {
266
+ if (entry.node.parentNode) entry.node.parentNode.removeChild(entry.node);
267
+ }
268
+ cache = next;
269
+
270
+ // Place nodes in order, just before the anchor (works even after the
271
+ // initial fragment has been flushed into the real parent).
272
+ const host = anchor.parentNode || parent;
273
+ let ref = anchor;
274
+ for (let i = nodes.length - 1; i >= 0; i--) {
275
+ const node = nodes[i];
276
+ if (node.nextSibling !== ref) host.insertBefore(node, ref);
277
+ ref = node;
278
+ }
279
+ });
280
+ }
@@ -0,0 +1,109 @@
1
+ //! ErrorBoundary — catch render/mount errors from descendant components and show
2
+ //! a fallback instead of letting the broken subtree (or its siblings) take the
3
+ //! page down.
4
+ //
5
+ // <ErrorBoundary fallback={(error, reset) => `Oops: ${error.message}`}>
6
+ // <Risky/>
7
+ // </ErrorBoundary>
8
+ //
9
+ // A descendant component's connectedCallback funnels a thrown error through
10
+ // `handleError(this, …)` (emitted by the compiler): it reports the error (dev
11
+ // overlay / logging) and walks up the DOM — hopping across portals, like
12
+ // `readContext` — to the nearest `web-error-boundary`, which swaps its children
13
+ // for the fallback. `reset()` rebuilds the original subtree (snapshotted before it
14
+ // ran) to retry. Only synchronous render/mount errors are caught; later
15
+ // effect/event errors still go to global reporting (as with React boundaries).
16
+
17
+ import { reportError } from "../core/errors.js";
18
+
19
+ /**
20
+ * Report a caught render/mount error and route it to the nearest enclosing
21
+ * `<ErrorBoundary>`. Called from a throwing component's connectedCallback catch.
22
+ */
23
+ export function handleError(el, error, context = {}) {
24
+ reportError(error, context); // always log / surface in the dev overlay
25
+ let node = el;
26
+ while (node) {
27
+ const boundary = node.closest ? node.closest("web-error-boundary") : null;
28
+ if (boundary) {
29
+ boundary.catch(error, context);
30
+ return;
31
+ }
32
+ const portal = node.closest ? node.closest("[data-otfw-portal]") : null;
33
+ node = portal ? portal.__portalHost : null;
34
+ }
35
+ }
36
+
37
+ // A self-contained fallback (inline-styled so it reads fine without any app CSS).
38
+ function defaultFallback(message, reset) {
39
+ const box = document.createElement("div");
40
+ box.setAttribute("role", "alert");
41
+ box.setAttribute("data-otfw-fallback", "");
42
+ box.style.cssText =
43
+ "display:flex;align-items:center;gap:.75rem;padding:.75rem 1rem;border:1px solid #ef4444;" +
44
+ "border-radius:.5rem;background:rgba(239,68,68,.1);color:#fca5a5;font:14px system-ui,sans-serif";
45
+ const text = document.createElement("span");
46
+ text.textContent = message;
47
+ box.appendChild(text);
48
+ const retry = document.createElement("button");
49
+ retry.textContent = "Retry";
50
+ retry.style.cssText =
51
+ "margin-left:auto;padding:.25rem .75rem;border-radius:.375rem;border:none;cursor:pointer;" +
52
+ "background:#ef4444;color:#fff;font:inherit";
53
+ retry.addEventListener("click", reset);
54
+ box.appendChild(retry);
55
+ return box;
56
+ }
57
+
58
+ export class ErrorBoundaryElement extends HTMLElement {
59
+ set fallback(fn) {
60
+ this._fallback = fn;
61
+ }
62
+ get fallback() {
63
+ return this._fallback;
64
+ }
65
+
66
+ connectedCallback() {
67
+ if (this._mounted) return;
68
+ this._mounted = true;
69
+ // Snapshot the children before they connect (and possibly throw) so reset()
70
+ // can rebuild a pristine subtree.
71
+ this._blueprint = Array.from(this.childNodes).map((n) => n.cloneNode(true));
72
+ }
73
+
74
+ // Invoked by handleError when a descendant throws during render/mount. Deferred
75
+ // to a microtask so we never mutate the tree mid-connectedCallback (re-entrancy).
76
+ catch(error) {
77
+ if (this._failed) return; // first error wins; ignore the cascade
78
+ this._failed = true;
79
+ queueMicrotask(() => this.#renderFallback(error));
80
+ }
81
+
82
+ #renderFallback(error) {
83
+ this.replaceChildren(); // tear the broken subtree down (runs disconnect cleanups)
84
+ const reset = () => this.reset();
85
+ let view = null;
86
+ try {
87
+ view = this._fallback ? this._fallback(error, reset) : null;
88
+ } catch (e) {
89
+ reportError(e, { phase: "fallback" });
90
+ }
91
+ const message = typeof view === "string" ? view : (error && error.message) || "Something went wrong";
92
+ this.appendChild(view instanceof Node ? view : defaultFallback(message, reset));
93
+ }
94
+
95
+ reset() {
96
+ if (!this._failed) return;
97
+ this._failed = false;
98
+ // Re-clone the blueprint: the rebuilt subtree connects fresh and may throw
99
+ // again (→ catch), or render cleanly if the cause is gone.
100
+ this.replaceChildren(...this._blueprint.map((n) => n.cloneNode(true)));
101
+ }
102
+ }
103
+
104
+ // Exported so `import { ErrorBoundary } from "@opentf/web"` resolves; the import
105
+ // side effect registers the element (JSX uses the `web-error-boundary` tag).
106
+ export const ErrorBoundary = ErrorBoundaryElement;
107
+ if (typeof customElements !== "undefined" && !customElements.get("web-error-boundary")) {
108
+ customElements.define("web-error-boundary", ErrorBoundaryElement);
109
+ }
@@ -0,0 +1,17 @@
1
+ //! Events — the dispatch half of the hybrid event model.
2
+ //
3
+ // Two channels (SPEC §5.4):
4
+ // • callback props — `<Picker onSelect={fn}>` sets a property; the component
5
+ // calls `this.onSelect?.(detail)`. Direct parent→child, no bubbling.
6
+ // • event listeners — `<Picker onSelect:listen={fn}>` compiles to
7
+ // `addEventListener("select", fn, opts)` (with `:capture`/`:passive`/`:once`),
8
+ // for bubbling/interop/multiple subscribers. `emit` is the matching dispatch.
9
+ //
10
+ // `emit(el, "select", detail)` fires a CustomEvent that bubbles and crosses shadow
11
+ // boundaries by default, so a `web-*` element is a first-class event source for any
12
+ // framework consuming it. Returns false if a listener called preventDefault().
13
+
14
+ export function emit(el, name, detail, options = {}) {
15
+ const { bubbles = true, composed = true, cancelable = false } = options;
16
+ return el.dispatchEvent(new CustomEvent(name, { detail, bubbles, composed, cancelable }));
17
+ }
@@ -0,0 +1,16 @@
1
+ // Built-in custom elements self-register on import. They're used in JSX as *tags*
2
+ // (`<Portal>` → `web-portal`), so their bindings are never referenced and a plain
3
+ // re-export would be tree-shaken away (the element never registers). Bare imports
4
+ // force the registration side effect to be retained.
5
+ import "./context.js";
6
+ import "./error-boundary.js";
7
+ import "./portal.js";
8
+
9
+ export * from "./dom.js";
10
+ export * from "./events.js";
11
+ export * from "./mount.js";
12
+ export * from "./lifecycle.js";
13
+ export * from "./router.js";
14
+ export * from "./context.js";
15
+ export * from "./error-boundary.js";
16
+ export * from "./portal.js";
@@ -0,0 +1,9 @@
1
+ // Lifecycle hooks. `onMount`/`onCleanup` are compiler macros: the OpenTF
2
+ // compiler collects their callbacks and wires them into a component's
3
+ // connect/disconnect (Custom Element) or a page/layout factory's `__lifecycle`
4
+ // record. These exports exist so the source-level `import { onMount } from
5
+ // "@opentf/web"` resolves, and so calling them outside a compiled component (or
6
+ // during SSR) is a safe no-op rather than a crash.
7
+
8
+ export function onMount() {}
9
+ export function onCleanup() {}
@@ -0,0 +1,49 @@
1
+ // Mounting a factory-style view (pages/layouts compile to factory functions;
2
+ // SPEC §2.2). Components compile to Custom Elements and mount via the DOM.
3
+
4
+ import { reportError } from "../core/errors.js";
5
+
6
+ /**
7
+ * Mount a view into `target`. `view` is either a factory function returning a
8
+ * DOM node or an already-built node. Returns the mounted node.
9
+ *
10
+ * Page/layout factories may attach a `__lifecycle` record (`{ mounts, cleanups }`)
11
+ * built from `onMount`/`onCleanup`. After insertion we run the `onMount` callbacks,
12
+ * collecting any returned disposer into `cleanups` so a caller (e.g. the router)
13
+ * can tear the page down on navigation.
14
+ */
15
+ export function mount(view, target) {
16
+ const node = typeof view === "function" ? view() : view;
17
+ target.appendChild(node);
18
+ runMount(node);
19
+ return node;
20
+ }
21
+
22
+ /** Run a node's `onMount` callbacks, collecting returned disposers as cleanups. */
23
+ export function runMount(node) {
24
+ const lc = node && node.__lifecycle;
25
+ if (!lc) return;
26
+ for (const cb of lc.mounts) {
27
+ try {
28
+ const disposer = cb();
29
+ if (typeof disposer === "function") lc.cleanups.push(disposer);
30
+ } catch (e) {
31
+ reportError(e, { phase: "mount" });
32
+ }
33
+ }
34
+ lc.mounts = [];
35
+ }
36
+
37
+ /** Run a node's collected `onCleanup`/disposer teardown (idempotent). */
38
+ export function runCleanup(node) {
39
+ const lc = node && node.__lifecycle;
40
+ if (!lc) return;
41
+ for (const cb of lc.cleanups) {
42
+ try {
43
+ cb();
44
+ } catch (e) {
45
+ console.error(e);
46
+ }
47
+ }
48
+ lc.cleanups = [];
49
+ }
@@ -0,0 +1,59 @@
1
+ //! Portal — render a subtree into a different DOM location (a modal/tooltip/toast
2
+ //! escaping an ancestor's overflow/z-index/transform).
3
+ //
4
+ // <Portal to="#toast-root"> … </Portal> // selector, element, or default body
5
+ //
6
+ // `<Portal>` compiles to this built-in `web-portal` element (capitalized name →
7
+ // tag, like <ContextProvider>) — no compiler changes. At connect it moves its
8
+ // light-DOM children to the target; on disconnect it removes them so their
9
+ // custom-element disconnect + effect cleanups run (the part userland leaks).
10
+ //
11
+ // Context is preserved across the boundary: each relocated root is marked
12
+ // `data-otfw-portal` with a `__portalHost` back-pointer to this element, and
13
+ // `readContext` (context.js) hops across it. Note that for modals/dialogs the
14
+ // native top layer (`<dialog>.showModal()`, the popover API) is usually a better
15
+ // fit than a portal — Portal is the general escape hatch for everything else.
16
+
17
+ function resolveTarget(to) {
18
+ if (to && to.nodeType === 1) return to; // an element
19
+ if (typeof to === "string" && to) return document.querySelector(to) || document.body;
20
+ return document.body;
21
+ }
22
+
23
+ export class PortalElement extends HTMLElement {
24
+ set to(v) {
25
+ this._to = v;
26
+ }
27
+ get to() {
28
+ return this._to;
29
+ }
30
+
31
+ connectedCallback() {
32
+ if (this._mounted) return;
33
+ this._mounted = true;
34
+ const target = resolveTarget(this._to);
35
+ // Snapshot children, mark each element root for the context hop, then move
36
+ // them — markers must be set before the move connects them at the target.
37
+ this._moved = Array.from(this.childNodes);
38
+ for (const n of this._moved) {
39
+ if (n.nodeType === 1) {
40
+ n.__portalHost = this;
41
+ n.setAttribute("data-otfw-portal", "");
42
+ }
43
+ }
44
+ for (const n of this._moved) target.appendChild(n);
45
+ }
46
+
47
+ disconnectedCallback() {
48
+ for (const n of this._moved || []) n.remove(); // triggers descendants' cleanup
49
+ this._moved = [];
50
+ this._mounted = false;
51
+ }
52
+ }
53
+
54
+ // Exported so `import { Portal } from "@opentf/web"` resolves; the import side
55
+ // effect registers the element (JSX uses the `web-portal` tag).
56
+ export const Portal = PortalElement;
57
+ if (typeof customElements !== "undefined" && !customElements.get("web-portal")) {
58
+ customElements.define("web-portal", PortalElement);
59
+ }