@opentf/web 0.5.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.
- package/README.md +43 -0
- package/components/Link.jsx +18 -4
- package/core/reactive.js +190 -0
- package/core/signals.js +19 -0
- package/index.js +1 -0
- package/package.json +1 -1
- package/runtime/clipboard.js +47 -0
- package/runtime/code-block.js +47 -0
- package/runtime/context.js +13 -3
- package/runtime/dom.js +109 -21
- package/runtime/error-boundary.js +5 -5
- package/runtime/hydrate.js +121 -0
- package/runtime/index.js +7 -1
- package/runtime/portal.js +4 -4
- package/runtime/raw-html.js +33 -0
- package/runtime/router.js +156 -10
- package/server/builtins.js +8 -3
- package/server/head.js +191 -0
- package/server/index.js +1 -0
- package/server/render.js +35 -13
- package/server/ssg-runtime.js +4 -1
package/README.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# @opentf/web
|
|
2
|
+
|
|
3
|
+
The native-first **OTF Web** runtime — signal-based reactivity and zero-VDOM DOM
|
|
4
|
+
operations, paired with the IR-based compiler ([`@opentf/web-compiler`](https://github.com/Open-Tech-Foundation/Web-App-Framework/tree/main/packages/web-compiler)).
|
|
5
|
+
|
|
6
|
+
You normally don't call this API by hand. You write `.jsx` components; the compiler
|
|
7
|
+
lowers them to plain DOM code that imports its helpers (`signal`, `computed`,
|
|
8
|
+
`bindText`, `bindList`, …) from this package. Components compile to self-registering
|
|
9
|
+
`web-*` Custom Elements.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
bun add @opentf/web
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Scaffold a ready-to-run app with [`@opentf/create-web`](https://github.com/Open-Tech-Foundation/Web-App-Framework/tree/main/packages/create-web)
|
|
18
|
+
and drive it with [`@opentf/web-cli`](https://github.com/Open-Tech-Foundation/Web-App-Framework/tree/main/packages/web-cli).
|
|
19
|
+
|
|
20
|
+
## What's inside
|
|
21
|
+
|
|
22
|
+
- **Reactivity** — a from-scratch `signal` / `computed` / `effect` core.
|
|
23
|
+
- **DOM runtime** — fine-grained bindings (`bindText`, `bindAttr`, `bindList`,
|
|
24
|
+
`bindChild`, `setProp`, `spread`, `clsx`) the compiler targets, plus `Context`,
|
|
25
|
+
`Portal`, `ErrorBoundary`, and event dispatch (`emit`).
|
|
26
|
+
- **Compiler macros** — authored in your components, resolved at compile time:
|
|
27
|
+
`$state`, `$derived`, `$ref`, `$context`, `$effect`, `$expose`, and the
|
|
28
|
+
`onMount` / `onCleanup` lifecycle hooks.
|
|
29
|
+
- **`<Link>`** — client-side navigation, shipped as JSX source and compiled by your
|
|
30
|
+
app's pipeline.
|
|
31
|
+
|
|
32
|
+
## Entry points
|
|
33
|
+
|
|
34
|
+
| Import | Contents |
|
|
35
|
+
| --- | --- |
|
|
36
|
+
| `@opentf/web` | runtime + reactivity + `Link` (what compiled components import) |
|
|
37
|
+
| `@opentf/web/signals` | the reactivity core on its own |
|
|
38
|
+
| `@opentf/web/runtime` | DOM helpers, Context, Portal, ErrorBoundary |
|
|
39
|
+
| `@opentf/web/server` | string-composed render helpers for SSG |
|
|
40
|
+
|
|
41
|
+
## License
|
|
42
|
+
|
|
43
|
+
MIT © [Open Tech Foundation](https://github.com/Open-Tech-Foundation)
|
package/components/Link.jsx
CHANGED
|
@@ -9,20 +9,34 @@
|
|
|
9
9
|
// `class` is a declared prop, so it lands on the rendered <a> (the link), not on
|
|
10
10
|
// the <web-link> host — no duplicated styling.
|
|
11
11
|
|
|
12
|
-
import { navigate } from "../runtime/router.js";
|
|
12
|
+
import { localizePath, navigate, shouldInterceptNav } from "../runtime/router.js";
|
|
13
|
+
|
|
14
|
+
export default function Link({ href, class: className, reload, children }) {
|
|
15
|
+
// Keep the link in the active locale (no-op when i18n is off). Locale is a
|
|
16
|
+
// per-load constant under URL-prefix routing, so resolving it once here is
|
|
17
|
+
// correct — a navigation that changes the locale rebuilds this component.
|
|
18
|
+
const target = localizePath(href);
|
|
19
|
+
// `reload` (a bare attribute → "") forces a full-page navigation for this link.
|
|
20
|
+
// Absent resolves to null (getAttribute), so use loose `!= null` to treat only a
|
|
21
|
+
// present, non-false value as opting out.
|
|
22
|
+
const forceReload = reload != null && reload !== false;
|
|
13
23
|
|
|
14
|
-
export default function Link({ href, class: className, children }) {
|
|
15
24
|
const onclick = (e) => {
|
|
16
25
|
// Let the browser handle modified clicks (new tab, etc.) and non-primary buttons.
|
|
17
26
|
if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) {
|
|
18
27
|
return;
|
|
19
28
|
}
|
|
29
|
+
// Full-page navigation when this link opts out (`reload`) or the app runs in MPA
|
|
30
|
+
// mode (docs/HYDRATION.md §7) — leave the click to the browser.
|
|
31
|
+
if (forceReload || !shouldInterceptNav()) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
20
34
|
e.preventDefault();
|
|
21
|
-
navigate(
|
|
35
|
+
navigate(target);
|
|
22
36
|
};
|
|
23
37
|
|
|
24
38
|
return (
|
|
25
|
-
<a href={
|
|
39
|
+
<a href={target} class={className} onclick={onclick}>
|
|
26
40
|
{children}
|
|
27
41
|
</a>
|
|
28
42
|
);
|
package/core/reactive.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
//! Deep reactive store — a fine-grained, signal-backed reactive object.
|
|
2
|
+
//
|
|
3
|
+
// `reactive(obj)` returns a deep proxy over plain data. Reading a property
|
|
4
|
+
// inside an effect/computed subscribes to *just that path*; writing a property
|
|
5
|
+
// notifies *only* the effects that read it. Object/array nodes are wrapped
|
|
6
|
+
// lazily on access, so a 100-field store costs nothing until a field is touched.
|
|
7
|
+
//
|
|
8
|
+
// This is the public primitive SPEC §1.1 requires to live in `@opentf/web`:
|
|
9
|
+
// apps `import { reactive }` and read values directly (no provider object), and
|
|
10
|
+
// `@opentf/web-form` builds its values/errors trees on top of it instead of a
|
|
11
|
+
// private proxy. It is forward-compatible with the `$signal()` compiler bridge
|
|
12
|
+
// (SPEC §3.5): each node is itself reactive, so a future First-Access unwrap can
|
|
13
|
+
// read `store.user.value` without changing this runtime.
|
|
14
|
+
//
|
|
15
|
+
// Model (mirrors the signals core, one level up):
|
|
16
|
+
// - per *key* signal → fine-grained leaf subscription (read `a.b.c`)
|
|
17
|
+
// - per *node* "struct" signal → bumped on key add/remove so `in`, `ownKeys`,
|
|
18
|
+
// iteration and array `length` stay reactive.
|
|
19
|
+
// State is mutated in place (the store owns it); reads always go through the
|
|
20
|
+
// proxy, so callers never observe the raw object directly. No per-write tree
|
|
21
|
+
// clone, no broadcast — the two costs the old form proxy paid on every keystroke.
|
|
22
|
+
|
|
23
|
+
import { signal, untracked, batch } from "./signals.js";
|
|
24
|
+
|
|
25
|
+
/** Unwrap a reactive proxy back to its underlying plain object. */
|
|
26
|
+
const RAW = Symbol.for("otfw.reactive.raw");
|
|
27
|
+
/** Brand a proxy so `isReactive` is a cheap, collision-free check. */
|
|
28
|
+
const IS_REACTIVE = Symbol.for("otfw.reactive.is");
|
|
29
|
+
|
|
30
|
+
// Per raw-object bookkeeping, kept off the data itself so the data stays clean.
|
|
31
|
+
const proxyCache = new WeakMap(); // raw object → its proxy (stable identity)
|
|
32
|
+
const keySignals = new WeakMap(); // raw object → Map<key, signal(value)>
|
|
33
|
+
const structSignals = new WeakMap(); // raw object → signal(int) bumped on shape change
|
|
34
|
+
|
|
35
|
+
// Array methods that mutate in place; routed through a `batch` so the many
|
|
36
|
+
// internal index/length writes collapse into a single notification.
|
|
37
|
+
const ARRAY_MUTATORS = new Set([
|
|
38
|
+
"push", "pop", "shift", "unshift", "splice", "reverse", "sort", "fill", "copyWithin",
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
/** Only plain objects and arrays are wrapped; class instances, Dates, etc. pass through. */
|
|
42
|
+
function isWrappable(v) {
|
|
43
|
+
if (v === null || typeof v !== "object") return false;
|
|
44
|
+
if (Array.isArray(v)) return true;
|
|
45
|
+
const proto = Object.getPrototypeOf(v);
|
|
46
|
+
return proto === Object.prototype || proto === null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function getKeySig(target, key) {
|
|
50
|
+
let map = keySignals.get(target);
|
|
51
|
+
if (!map) keySignals.set(target, (map = new Map()));
|
|
52
|
+
let sig = map.get(key);
|
|
53
|
+
if (!sig) map.set(key, (sig = signal(target[key])));
|
|
54
|
+
return sig;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function getStructSig(target) {
|
|
58
|
+
let sig = structSignals.get(target);
|
|
59
|
+
if (!sig) structSignals.set(target, (sig = signal(0)));
|
|
60
|
+
return sig;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Strip a reactive wrapper from a value before it is stored. */
|
|
64
|
+
function toRaw(v) {
|
|
65
|
+
return v && typeof v === "object" && v[RAW] !== undefined ? v[RAW] : v;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const handler = {
|
|
69
|
+
get(target, key, receiver) {
|
|
70
|
+
if (key === RAW) return target;
|
|
71
|
+
if (key === IS_REACTIVE) return true;
|
|
72
|
+
|
|
73
|
+
if (typeof key === "symbol") {
|
|
74
|
+
// Spreading/iterating walks length+indices through the proxy, but the
|
|
75
|
+
// iterator lookup itself should subscribe to shape so a re-spread re-runs.
|
|
76
|
+
if (key === Symbol.iterator) getStructSig(target).value;
|
|
77
|
+
return Reflect.get(target, key, receiver);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// `length` is structural for arrays — push/splice change it.
|
|
81
|
+
if (key === "length" && Array.isArray(target)) {
|
|
82
|
+
getStructSig(target).value;
|
|
83
|
+
return target.length;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const value = target[key];
|
|
87
|
+
|
|
88
|
+
if (typeof value === "function") {
|
|
89
|
+
if (Array.isArray(target)) {
|
|
90
|
+
// Mutators re-enter the `set` trap via `receiver`; batch their internal
|
|
91
|
+
// writes so an effect re-runs once.
|
|
92
|
+
if (ARRAY_MUTATORS.has(key)) {
|
|
93
|
+
return (...args) => batch(() => value.apply(receiver, args));
|
|
94
|
+
}
|
|
95
|
+
// Read methods (map/filter/forEach/…) subscribe to *structure* only and
|
|
96
|
+
// run over the raw array, so they re-run when the list grows/shrinks but
|
|
97
|
+
// not when an individual element's value changes. This is what keeps a
|
|
98
|
+
// `list.map(i => <input/>)` from re-rendering (and dropping focus) on
|
|
99
|
+
// every keystroke — the element's own binding handles its value.
|
|
100
|
+
getStructSig(target).value;
|
|
101
|
+
return value.bind(target);
|
|
102
|
+
}
|
|
103
|
+
return value.bind(receiver);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
getKeySig(target, key).value; // subscribe to this leaf/branch
|
|
107
|
+
return isWrappable(value) ? wrap(value) : value;
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
set(target, key, value) {
|
|
111
|
+
const raw = toRaw(value);
|
|
112
|
+
|
|
113
|
+
if (key === "length" && Array.isArray(target)) {
|
|
114
|
+
if (target.length !== raw) {
|
|
115
|
+
target.length = raw;
|
|
116
|
+
getStructSig(target).value++;
|
|
117
|
+
}
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const had = Object.prototype.hasOwnProperty.call(target, key);
|
|
122
|
+
if (had && Object.is(target[key], raw)) return true;
|
|
123
|
+
|
|
124
|
+
target[key] = raw;
|
|
125
|
+
const map = keySignals.get(target);
|
|
126
|
+
const sig = map && map.get(key);
|
|
127
|
+
if (sig) sig.value = raw; // notify only if someone is listening
|
|
128
|
+
if (!had) getStructSig(target).value++; // new key → shape changed
|
|
129
|
+
return true;
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
deleteProperty(target, key) {
|
|
133
|
+
if (!Object.prototype.hasOwnProperty.call(target, key)) return true;
|
|
134
|
+
delete target[key];
|
|
135
|
+
const map = keySignals.get(target);
|
|
136
|
+
const sig = map && map.get(key);
|
|
137
|
+
if (sig) sig.value = undefined;
|
|
138
|
+
getStructSig(target).value++;
|
|
139
|
+
return true;
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
has(target, key) {
|
|
143
|
+
getStructSig(target).value;
|
|
144
|
+
return Reflect.has(target, key);
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
ownKeys(target) {
|
|
148
|
+
getStructSig(target).value;
|
|
149
|
+
return Reflect.ownKeys(target);
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
function wrap(target) {
|
|
154
|
+
const cached = proxyCache.get(target);
|
|
155
|
+
if (cached) return cached;
|
|
156
|
+
const proxy = new Proxy(target, handler);
|
|
157
|
+
proxyCache.set(target, proxy);
|
|
158
|
+
return proxy;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Create a deep reactive store over a plain object/array. Property reads inside
|
|
163
|
+
* an effect subscribe fine-grained; writes (`s.a.b = x`, `s.items.push(y)`)
|
|
164
|
+
* notify only dependents of the touched paths.
|
|
165
|
+
*/
|
|
166
|
+
export function reactive(init = {}) {
|
|
167
|
+
if (!isWrappable(init)) {
|
|
168
|
+
throw new TypeError("reactive() expects a plain object or array");
|
|
169
|
+
}
|
|
170
|
+
return wrap(init);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Whether `v` is a store created by `reactive()`. */
|
|
174
|
+
export function isReactive(v) {
|
|
175
|
+
return !!(v && typeof v === "object" && v[IS_REACTIVE]);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The underlying plain object of a store (live, not a copy); identity for non-stores. */
|
|
179
|
+
export function toRawValue(v) {
|
|
180
|
+
return isReactive(v) ? v[RAW] : v;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* A plain, non-reactive deep copy of a store's current value — for submit
|
|
185
|
+
* payloads, validation input, or persistence. Reads are untracked, so taking a
|
|
186
|
+
* snapshot never subscribes the caller.
|
|
187
|
+
*/
|
|
188
|
+
export function snapshot(v) {
|
|
189
|
+
return untracked(() => structuredClone(toRawValue(v)));
|
|
190
|
+
}
|
package/core/signals.js
CHANGED
|
@@ -94,9 +94,28 @@ export function signal(initial) {
|
|
|
94
94
|
propagate(node);
|
|
95
95
|
if (batchDepth === 0) flush();
|
|
96
96
|
},
|
|
97
|
+
/** Read the current value without subscribing the active consumer. */
|
|
98
|
+
peek() {
|
|
99
|
+
return node.value;
|
|
100
|
+
},
|
|
97
101
|
};
|
|
98
102
|
}
|
|
99
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Run `fn` without subscribing the active consumer to any signals it reads.
|
|
106
|
+
* Use inside an effect/computed to read reactive state without depending on it
|
|
107
|
+
* (e.g. validation that reads many fields but should not re-run on each).
|
|
108
|
+
*/
|
|
109
|
+
export function untracked(fn) {
|
|
110
|
+
const prev = activeConsumer;
|
|
111
|
+
activeConsumer = null;
|
|
112
|
+
try {
|
|
113
|
+
return fn();
|
|
114
|
+
} finally {
|
|
115
|
+
activeConsumer = prev;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
100
119
|
/**
|
|
101
120
|
* A derived, read-only reactive value. Lazy and cached: the compute function
|
|
102
121
|
* runs only when `.value` is read while stale, and recomputes only after a
|
package/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Public entry for the OTF Web runtime: reactivity + DOM operations.
|
|
2
2
|
// CSR-generated modules import their helpers (signal/computed/bindText/…) from here.
|
|
3
3
|
export * from "./core/signals.js";
|
|
4
|
+
export * from "./core/reactive.js";
|
|
4
5
|
export * from "./core/errors.js";
|
|
5
6
|
export * from "./runtime/index.js";
|
|
6
7
|
// `Link` is shipped as JSX source and compiled by the consuming app's pipeline
|
package/package.json
CHANGED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
//! Clipboard helpers for "copy" affordances (code blocks, etc.).
|
|
2
|
+
//
|
|
3
|
+
// Behavior travels with the component that needs it — these are imported by the
|
|
4
|
+
// `web-internal-code-block` built-in and by `@opentf/web-docs`'s `CodeBlock`, so a
|
|
5
|
+
// copy button works wherever it's rendered, with no cooperation from an ancestor
|
|
6
|
+
// layout. `copyText` prefers the async Clipboard API and falls back to a hidden
|
|
7
|
+
// `<textarea>` + `execCommand` for non-secure contexts / older browsers.
|
|
8
|
+
|
|
9
|
+
export function copyText(text) {
|
|
10
|
+
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
|
|
11
|
+
navigator.clipboard.writeText(text).catch(() => fallbackCopy(text));
|
|
12
|
+
} else {
|
|
13
|
+
fallbackCopy(text);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function fallbackCopy(text) {
|
|
18
|
+
try {
|
|
19
|
+
const ta = document.createElement("textarea");
|
|
20
|
+
ta.value = text;
|
|
21
|
+
ta.style.position = "fixed";
|
|
22
|
+
ta.style.opacity = "0";
|
|
23
|
+
document.body.appendChild(ta);
|
|
24
|
+
ta.select();
|
|
25
|
+
document.execCommand("copy");
|
|
26
|
+
document.body.removeChild(ta);
|
|
27
|
+
} catch {
|
|
28
|
+
/* clipboard unavailable */
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Copy `text`, then give `button` the standard "Copied" feedback: add `is-copied`,
|
|
34
|
+
* swap its `.otfw-copy-label` to "Copied", and revert after 2s. Idempotent across
|
|
35
|
+
* rapid clicks (the pending revert is reset each time).
|
|
36
|
+
*/
|
|
37
|
+
export function copyWithFeedback(button, text) {
|
|
38
|
+
copyText(text);
|
|
39
|
+
const label = button.querySelector(".otfw-copy-label");
|
|
40
|
+
button.classList.add("is-copied");
|
|
41
|
+
if (label) label.textContent = "Copied";
|
|
42
|
+
clearTimeout(button._otfwCopyT);
|
|
43
|
+
button._otfwCopyT = setTimeout(() => {
|
|
44
|
+
button.classList.remove("is-copied");
|
|
45
|
+
if (label) label.textContent = "Copy";
|
|
46
|
+
}, 2000);
|
|
47
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
//! Code block — a built-in that renders a (build-time syntax-highlighted) code block
|
|
2
|
+
//! and **owns its copy behavior**.
|
|
3
|
+
//
|
|
4
|
+
// <CodeFence html={"<div class='otfw-code'>…<button class='otfw-copy'>…</button><pre>…</pre></div>"} />
|
|
5
|
+
//
|
|
6
|
+
// The MDX front-end emits this for fenced code (compiles to `web-internal-code-block`,
|
|
7
|
+
// like `<RawHtml>` → `web-internal-raw-html`). The highlighted markup is a trusted
|
|
8
|
+
// string from the compiler, so it's assigned to `innerHTML`; the difference from
|
|
9
|
+
// `RawHtml` is that on connect this element wires its own `.otfw-copy` button to copy
|
|
10
|
+
// the `<pre>` text. That keeps copy working wherever a code block is rendered (docs,
|
|
11
|
+
// blog, anywhere) — no delegated listener in a layout. SSG renders the string inline
|
|
12
|
+
// (server/builtins.js); the behavior wires when the element upgrades in the browser.
|
|
13
|
+
|
|
14
|
+
import { copyWithFeedback } from "./clipboard.js";
|
|
15
|
+
|
|
16
|
+
export class CodeBlockElement extends HTMLElement {
|
|
17
|
+
set html(v) {
|
|
18
|
+
this._html = v;
|
|
19
|
+
if (this.isConnected) this.render();
|
|
20
|
+
}
|
|
21
|
+
get html() {
|
|
22
|
+
return this._html;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
connectedCallback() {
|
|
26
|
+
// The `html` property is set before append during a CSR build; render once.
|
|
27
|
+
if (!this._rendered) this.render();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
render() {
|
|
31
|
+
this.innerHTML = this._html == null ? "" : String(this._html);
|
|
32
|
+
const button = this.querySelector(".otfw-copy");
|
|
33
|
+
const pre = this.querySelector("pre");
|
|
34
|
+
if (button && pre && !button._otfwWired) {
|
|
35
|
+
button._otfwWired = true;
|
|
36
|
+
button.addEventListener("click", () => copyWithFeedback(button, pre.innerText));
|
|
37
|
+
}
|
|
38
|
+
this._rendered = true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Exported so the bare import in runtime/index.js retains the registration side
|
|
43
|
+
// effect; JSX addresses it by the `web-internal-code-block` tag (no import needed).
|
|
44
|
+
export const CodeFence = CodeBlockElement;
|
|
45
|
+
if (typeof customElements !== "undefined" && !customElements.get("web-internal-code-block")) {
|
|
46
|
+
customElements.define("web-internal-code-block", CodeBlockElement);
|
|
47
|
+
}
|
package/runtime/context.js
CHANGED
|
@@ -32,6 +32,16 @@ export function exitHost() {
|
|
|
32
32
|
hostStack.pop();
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* The component host element currently executing its body, or `null` outside a
|
|
37
|
+
* component (e.g. a form created in module scope or a test). Libraries use this
|
|
38
|
+
* as a per-instance identity to scope state to the mounting component — the
|
|
39
|
+
* compiler emits `enterHost(this)` around every component body (csr.rs).
|
|
40
|
+
*/
|
|
41
|
+
export function getCurrentInstance() {
|
|
42
|
+
return hostStack[hostStack.length - 1] ?? null;
|
|
43
|
+
}
|
|
44
|
+
|
|
35
45
|
/**
|
|
36
46
|
* Create a context with a default value. The returned token is passed to
|
|
37
47
|
* `<ContextProvider context={…}>` and `$context(…)`.
|
|
@@ -82,8 +92,8 @@ export class ContextProviderElement extends HTMLElement {
|
|
|
82
92
|
}
|
|
83
93
|
|
|
84
94
|
// Exported so `import { ContextProvider } from "@opentf/web"` resolves; the import
|
|
85
|
-
// side effect registers the element (JSX uses the `web-context-provider` tag).
|
|
95
|
+
// side effect registers the element (JSX uses the `web-internal-context-provider` tag).
|
|
86
96
|
export const ContextProvider = ContextProviderElement;
|
|
87
|
-
if (typeof customElements !== "undefined" && !customElements.get("web-context-provider")) {
|
|
88
|
-
customElements.define("web-context-provider", ContextProviderElement);
|
|
97
|
+
if (typeof customElements !== "undefined" && !customElements.get("web-internal-context-provider")) {
|
|
98
|
+
customElements.define("web-internal-context-provider", ContextProviderElement);
|
|
89
99
|
}
|
package/runtime/dom.js
CHANGED
|
@@ -64,6 +64,13 @@ export function toText(value) {
|
|
|
64
64
|
return value == null || value === false ? "" : String(value);
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/** Sentinel for "nothing written yet" — distinct from any real attribute value. */
|
|
68
|
+
const UNSET = Symbol("unset");
|
|
69
|
+
|
|
70
|
+
/** A value safe to compare by identity for write elision (no internal mutation). */
|
|
71
|
+
const isPrimitive = (v) =>
|
|
72
|
+
v === null || (typeof v !== "object" && typeof v !== "function");
|
|
73
|
+
|
|
67
74
|
/**
|
|
68
75
|
* clsx-style class normalization. Falsy entries are skipped; strings/numbers are
|
|
69
76
|
* kept; arrays recurse; objects contribute the keys whose values are truthy. Lets
|
|
@@ -165,30 +172,64 @@ export function spread(el, obj, asProps) {
|
|
|
165
172
|
*/
|
|
166
173
|
export function bindText(node, fn) {
|
|
167
174
|
let inserted = [];
|
|
175
|
+
let lastText; // undefined sentinel — toText() never returns undefined
|
|
168
176
|
return effect(() => {
|
|
169
177
|
const value = fn();
|
|
178
|
+
|
|
179
|
+
// Primitive text: the common case. Skip the write when the rendered string
|
|
180
|
+
// is unchanged (a shared dependency can re-run this effect with the same
|
|
181
|
+
// result), so unchanged text nodes don't churn.
|
|
182
|
+
if (!(value instanceof Node) && !Array.isArray(value)) {
|
|
183
|
+
const text = toText(value);
|
|
184
|
+
if (text === lastText) return;
|
|
185
|
+
lastText = text;
|
|
186
|
+
for (const n of inserted) {
|
|
187
|
+
if (n.parentNode) n.parentNode.removeChild(n);
|
|
188
|
+
}
|
|
189
|
+
inserted = [];
|
|
190
|
+
node.data = text;
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Node / array value (e.g. JSX stored as a value): insert before the stable
|
|
195
|
+
// text anchor. Reset the text memo so a later primitive value re-applies.
|
|
196
|
+
lastText = undefined;
|
|
170
197
|
for (const n of inserted) {
|
|
171
198
|
if (n.parentNode) n.parentNode.removeChild(n);
|
|
172
199
|
}
|
|
173
200
|
inserted = [];
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
inserted.push(n);
|
|
181
|
-
}
|
|
201
|
+
node.data = "";
|
|
202
|
+
const parent = node.parentNode;
|
|
203
|
+
if (parent) {
|
|
204
|
+
for (const n of toNodes(value)) {
|
|
205
|
+
parent.insertBefore(n, node);
|
|
206
|
+
inserted.push(n);
|
|
182
207
|
}
|
|
183
|
-
} else {
|
|
184
|
-
node.data = toText(value);
|
|
185
208
|
}
|
|
186
209
|
});
|
|
187
210
|
}
|
|
188
211
|
|
|
189
|
-
/**
|
|
212
|
+
/**
|
|
213
|
+
* Wire an element attribute/prop to a reactive expression. Returns the disposer.
|
|
214
|
+
*
|
|
215
|
+
* No-op writes are elided for primitive values: when the expression recomputes
|
|
216
|
+
* to the same primitive it last wrote, the DOM write is skipped. This matters
|
|
217
|
+
* when many elements share one dependency — e.g. a keyed list where every row's
|
|
218
|
+
* `class={selected === row.id ? "danger" : ""}` subscribes to a single
|
|
219
|
+
* `selected` signal. Changing it re-runs every row's effect, but only the two
|
|
220
|
+
* rows whose class actually changes should touch the DOM; the rest would
|
|
221
|
+
* otherwise call `setAttribute` with an unchanged value and trigger needless
|
|
222
|
+
* style invalidation. Object values (style/class objects, spreads) may have
|
|
223
|
+
* mutated internally, so they are always re-applied.
|
|
224
|
+
*/
|
|
190
225
|
export function bindAttr(el, name, fn) {
|
|
191
|
-
|
|
226
|
+
let last = UNSET;
|
|
227
|
+
return effect(() => {
|
|
228
|
+
const value = fn();
|
|
229
|
+
if (isPrimitive(value) && Object.is(value, last)) return;
|
|
230
|
+
last = value;
|
|
231
|
+
setAttr(el, name, value);
|
|
232
|
+
});
|
|
192
233
|
}
|
|
193
234
|
|
|
194
235
|
/** Flatten a reactive child value into DOM nodes: nodes pass through, arrays
|
|
@@ -239,12 +280,20 @@ export function bindList(parent, sourceFn, renderItem, keyFn) {
|
|
|
239
280
|
const anchor = document.createComment("");
|
|
240
281
|
parent.appendChild(anchor);
|
|
241
282
|
let cache = new Map(); // key -> { sig, node }
|
|
283
|
+
let prevKeys = []; // keys in current DOM order, for minimal-move reconciliation
|
|
242
284
|
|
|
243
285
|
return effect(() => {
|
|
244
286
|
const data = sourceFn();
|
|
245
287
|
const items = Array.isArray(data) ? data : [];
|
|
246
288
|
const next = new Map();
|
|
247
|
-
const nodes =
|
|
289
|
+
const nodes = new Array(items.length);
|
|
290
|
+
const newKeys = new Array(items.length);
|
|
291
|
+
// For each new position, the node's index in the *previous* order, or -1 if
|
|
292
|
+
// it is newly created. Drives the LIS-based minimal move below.
|
|
293
|
+
const prevIndex = new Array(items.length);
|
|
294
|
+
|
|
295
|
+
const prevPos = new Map();
|
|
296
|
+
for (let i = 0; i < prevKeys.length; i++) prevPos.set(prevKeys[i], i);
|
|
248
297
|
|
|
249
298
|
for (let index = 0; index < items.length; index++) {
|
|
250
299
|
const item = items[index];
|
|
@@ -252,13 +301,16 @@ export function bindList(parent, sourceFn, renderItem, keyFn) {
|
|
|
252
301
|
let entry = cache.get(key);
|
|
253
302
|
if (entry) {
|
|
254
303
|
cache.delete(key);
|
|
255
|
-
entry.sig.value = item; // fine-grained per-item update
|
|
304
|
+
entry.sig.value = item; // fine-grained per-item update (no-op if unchanged)
|
|
305
|
+
prevIndex[index] = prevPos.has(key) ? prevPos.get(key) : -1;
|
|
256
306
|
} else {
|
|
257
307
|
const sig = signal(item);
|
|
258
308
|
entry = { sig, node: renderItem(sig, index) };
|
|
309
|
+
prevIndex[index] = -1;
|
|
259
310
|
}
|
|
260
311
|
next.set(key, entry);
|
|
261
|
-
|
|
312
|
+
newKeys[index] = key;
|
|
313
|
+
nodes[index] = entry.node;
|
|
262
314
|
}
|
|
263
315
|
|
|
264
316
|
// Remove nodes whose keys disappeared.
|
|
@@ -266,15 +318,51 @@ export function bindList(parent, sourceFn, renderItem, keyFn) {
|
|
|
266
318
|
if (entry.node.parentNode) entry.node.parentNode.removeChild(entry.node);
|
|
267
319
|
}
|
|
268
320
|
cache = next;
|
|
321
|
+
prevKeys = newKeys;
|
|
269
322
|
|
|
270
|
-
//
|
|
271
|
-
//
|
|
323
|
+
// Minimal-move placement. Nodes whose previous indices form the longest
|
|
324
|
+
// increasing subsequence are already in relative order and stay put; only
|
|
325
|
+
// the rest (and newly created nodes) are inserted. A 2-row swap moves 2
|
|
326
|
+
// nodes, not the ~n nodes a naive "fix every wrong nextSibling" pass would
|
|
327
|
+
// cascade into. Walk back to front so each insertion's reference node is
|
|
328
|
+
// already in its final position.
|
|
329
|
+
const keep = longestIncreasingRun(prevIndex);
|
|
272
330
|
const host = anchor.parentNode || parent;
|
|
273
|
-
let ref = anchor;
|
|
274
331
|
for (let i = nodes.length - 1; i >= 0; i--) {
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
ref
|
|
332
|
+
if (keep.has(i)) continue;
|
|
333
|
+
const ref = i + 1 < nodes.length ? nodes[i + 1] : anchor;
|
|
334
|
+
if (nodes[i].nextSibling !== ref) host.insertBefore(nodes[i], ref);
|
|
278
335
|
}
|
|
279
336
|
});
|
|
280
337
|
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Indices `i` of `seq` whose values form a longest strictly-increasing
|
|
341
|
+
* subsequence (patience sorting, O(n log n)). Entries equal to -1 (new nodes)
|
|
342
|
+
* are excluded — they always need insertion. Returned as a Set for O(1) lookup
|
|
343
|
+
* during placement; these are the nodes that can stay where they are.
|
|
344
|
+
*/
|
|
345
|
+
function longestIncreasingRun(seq) {
|
|
346
|
+
const piles = []; // piles[k] = index of the smallest tail of a length-(k+1) run
|
|
347
|
+
const prev = new Array(seq.length).fill(-1);
|
|
348
|
+
for (let i = 0; i < seq.length; i++) {
|
|
349
|
+
const v = seq[i];
|
|
350
|
+
if (v < 0) continue; // new node — never part of the stable run
|
|
351
|
+
let lo = 0;
|
|
352
|
+
let hi = piles.length;
|
|
353
|
+
while (lo < hi) {
|
|
354
|
+
const mid = (lo + hi) >> 1;
|
|
355
|
+
if (seq[piles[mid]] < v) lo = mid + 1;
|
|
356
|
+
else hi = mid;
|
|
357
|
+
}
|
|
358
|
+
if (lo > 0) prev[i] = piles[lo - 1];
|
|
359
|
+
piles[lo] = i;
|
|
360
|
+
}
|
|
361
|
+
const keep = new Set();
|
|
362
|
+
let k = piles.length ? piles[piles.length - 1] : -1;
|
|
363
|
+
while (k >= 0) {
|
|
364
|
+
keep.add(k);
|
|
365
|
+
k = prev[k];
|
|
366
|
+
}
|
|
367
|
+
return keep;
|
|
368
|
+
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// A descendant component's connectedCallback funnels a thrown error through
|
|
10
10
|
// `handleError(this, …)` (emitted by the compiler): it reports the error (dev
|
|
11
11
|
// overlay / logging) and walks up the DOM — hopping across portals, like
|
|
12
|
-
// `readContext` — to the nearest `web-error-boundary`, which swaps its children
|
|
12
|
+
// `readContext` — to the nearest `web-internal-error-boundary`, which swaps its children
|
|
13
13
|
// for the fallback. `reset()` rebuilds the original subtree (snapshotted before it
|
|
14
14
|
// ran) to retry. Only synchronous render/mount errors are caught; later
|
|
15
15
|
// effect/event errors still go to global reporting (as with React boundaries).
|
|
@@ -24,7 +24,7 @@ export function handleError(el, error, context = {}) {
|
|
|
24
24
|
reportError(error, context); // always log / surface in the dev overlay
|
|
25
25
|
let node = el;
|
|
26
26
|
while (node) {
|
|
27
|
-
const boundary = node.closest ? node.closest("web-error-boundary") : null;
|
|
27
|
+
const boundary = node.closest ? node.closest("web-internal-error-boundary") : null;
|
|
28
28
|
if (boundary) {
|
|
29
29
|
boundary.catch(error, context);
|
|
30
30
|
return;
|
|
@@ -102,8 +102,8 @@ export class ErrorBoundaryElement extends HTMLElement {
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
// Exported so `import { ErrorBoundary } from "@opentf/web"` resolves; the import
|
|
105
|
-
// side effect registers the element (JSX uses the `web-error-boundary` tag).
|
|
105
|
+
// side effect registers the element (JSX uses the `web-internal-error-boundary` tag).
|
|
106
106
|
export const ErrorBoundary = ErrorBoundaryElement;
|
|
107
|
-
if (typeof customElements !== "undefined" && !customElements.get("web-error-boundary")) {
|
|
108
|
-
customElements.define("web-error-boundary", ErrorBoundaryElement);
|
|
107
|
+
if (typeof customElements !== "undefined" && !customElements.get("web-internal-error-boundary")) {
|
|
108
|
+
customElements.define("web-internal-error-boundary", ErrorBoundaryElement);
|
|
109
109
|
}
|