@nisli/core 0.54.1 → 0.56.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 +119 -4
- package/dist/component.js +146 -5
- package/dist/component.js.map +1 -1
- package/dist/diagnostics.d.ts +48 -0
- package/dist/diagnostics.js +118 -0
- package/dist/diagnostics.js.map +1 -0
- package/dist/disposable.d.ts +14 -0
- package/dist/disposable.js +8 -0
- package/dist/disposable.js.map +1 -0
- package/dist/emitter.d.ts +2 -1
- package/dist/emitter.js +2 -1
- package/dist/emitter.js.map +1 -1
- package/dist/hmr/registry.js +8 -1
- package/dist/hmr/registry.js.map +1 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.js +9 -2
- package/dist/index.js.map +1 -1
- package/dist/injector.d.ts +9 -2
- package/dist/injector.js +18 -0
- package/dist/injector.js.map +1 -1
- package/dist/lifecycle.d.ts +9 -0
- package/dist/lifecycle.js +17 -7
- package/dist/lifecycle.js.map +1 -1
- package/dist/projection.js +24 -5
- package/dist/projection.js.map +1 -1
- package/dist/query.d.ts +134 -55
- package/dist/query.js +379 -188
- package/dist/query.js.map +1 -1
- package/dist/resource.d.ts +2 -1
- package/dist/resource.js +21 -4
- package/dist/resource.js.map +1 -1
- package/dist/settle.d.ts +61 -0
- package/dist/settle.js +106 -0
- package/dist/settle.js.map +1 -0
- package/dist/signal.d.ts +42 -7
- package/dist/signal.js +248 -68
- package/dist/signal.js.map +1 -1
- package/dist/template-audit.d.ts +64 -0
- package/dist/template-audit.js +246 -0
- package/dist/template-audit.js.map +1 -0
- package/dist/template.d.ts +69 -12
- package/dist/template.js +349 -66
- package/dist/template.js.map +1 -1
- package/dist/view-transition.d.ts +70 -0
- package/dist/view-transition.js +98 -0
- package/dist/view-transition.js.map +1 -0
- package/package.json +1 -1
package/dist/template.js
CHANGED
|
@@ -9,8 +9,111 @@
|
|
|
9
9
|
* 1. First render: parse template, clone, create bindings, mount
|
|
10
10
|
* 2. Updates: signal changes trigger individual binding updates (O(1))
|
|
11
11
|
*/
|
|
12
|
-
import { signal, isSignal, effect, computed, } from './signal.js';
|
|
12
|
+
import { signal, isSignal, effect, computed, untrack, } from './signal.js';
|
|
13
13
|
import { isRef } from './ref.js';
|
|
14
|
+
// First-parse audit (ADR 0030.2 T4). The import is static — core has no
|
|
15
|
+
// build-time dev/prod define yet (a named Wave-1 deliverable, §8) — but every
|
|
16
|
+
// entry point is a no-op behind template-audit's own dev flag.
|
|
17
|
+
import { auditTemplate, captureCallsite } from './template-audit.js';
|
|
18
|
+
const canMoveWithin = (parent) => typeof parent.moveBefore === 'function';
|
|
19
|
+
/**
|
|
20
|
+
* Value-returning untrack. signal.ts's `untrack` is `(fn: () => void) => void`
|
|
21
|
+
* (the value-returning form is a 0030.2 §3 papercut owned elsewhere); this
|
|
22
|
+
* local wrapper closes over the result until that lands.
|
|
23
|
+
*/
|
|
24
|
+
function untracked(fn) {
|
|
25
|
+
let result;
|
|
26
|
+
untrack(() => { result = fn(); });
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Mark a string as trusted raw HTML. `html:inner` accepts ONLY branded
|
|
31
|
+
* values — a bare string throws N106 (the accidental-XSS-sink class closes,
|
|
32
|
+
* ADR 0030.2 §3). NEVER wrap user-generated input.
|
|
33
|
+
*/
|
|
34
|
+
export function raw(value) {
|
|
35
|
+
return { __raw: true, value };
|
|
36
|
+
}
|
|
37
|
+
function isRawHtml(value) {
|
|
38
|
+
return (typeof value === 'object'
|
|
39
|
+
&& value !== null
|
|
40
|
+
&& '__raw' in value
|
|
41
|
+
&& value.__raw === true
|
|
42
|
+
&& typeof value.value === 'string');
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Mark a string as untrusted markup to be sanitized at the sink — the brand
|
|
46
|
+
* for user-generated HTML, which must NEVER go through `raw()`.
|
|
47
|
+
*
|
|
48
|
+
* `html:inner` writes it with the platform's `Element.setHTML()` (the spec's
|
|
49
|
+
* XSS-safe default sanitizer) when the engine has one, otherwise with the hook
|
|
50
|
+
* registered by {@link setSanitizerFallback}. With NEITHER available the
|
|
51
|
+
* binding throws N107: it never falls back to `innerHTML`. nisli bundles no
|
|
52
|
+
* sanitizer — the trust decision, and its bytes, stay in the app.
|
|
53
|
+
*/
|
|
54
|
+
export function sanitized(value) {
|
|
55
|
+
return { __sanitize: true, value };
|
|
56
|
+
}
|
|
57
|
+
function isSanitizedHtml(value) {
|
|
58
|
+
return (typeof value === 'object'
|
|
59
|
+
&& value !== null
|
|
60
|
+
&& '__sanitize' in value
|
|
61
|
+
&& value.__sanitize === true
|
|
62
|
+
&& typeof value.value === 'string');
|
|
63
|
+
}
|
|
64
|
+
let sanitizerFallback = null;
|
|
65
|
+
/**
|
|
66
|
+
* Register the sanitizing writer used for `sanitized()` values on engines
|
|
67
|
+
* WITHOUT native `Element.setHTML()` (Safari today, older Chromium/Firefox),
|
|
68
|
+
* or `null` to unregister. Native `setHTML()` always wins where it exists, so
|
|
69
|
+
* modern engines never pay the hook. nisli ships no sanitizer; wire one:
|
|
70
|
+
*
|
|
71
|
+
* setSanitizerFallback((el, html) => {
|
|
72
|
+
* el.innerHTML = DOMPurify.sanitize(html);
|
|
73
|
+
* });
|
|
74
|
+
*/
|
|
75
|
+
export function setSanitizerFallback(fn) {
|
|
76
|
+
sanitizerFallback = fn;
|
|
77
|
+
}
|
|
78
|
+
const nativeSetHtml = (el, html) => {
|
|
79
|
+
el.setHTML(html);
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* The sanitizing writer for this element, or `null` when the engine has none
|
|
83
|
+
* and the app registered none — the fail-closed signal N107 is thrown on.
|
|
84
|
+
*
|
|
85
|
+
* The method must be the one on `Element.prototype`, not merely *a* callable
|
|
86
|
+
* named `setHTML`. A duck-typed check here is a sanitizer bypass: an element
|
|
87
|
+
* carrying its own `setHTML` — DOM clobbering, an unsafe polyfill, a library
|
|
88
|
+
* shim — would be trusted as the platform sanitizer and would preempt the
|
|
89
|
+
* fallback the app explicitly registered as safe. Verified: with an
|
|
90
|
+
* own-property `setHTML`, the registered fallback was never called and
|
|
91
|
+
* `<img src=x onerror=…>` reached innerHTML intact.
|
|
92
|
+
*
|
|
93
|
+
* Read from the prototype per write rather than captured at module load, so a
|
|
94
|
+
* late polyfill or a test's stub is still honoured; identity is what is
|
|
95
|
+
* checked, not existence. Replacing `Element.prototype.setHTML` wholesale is
|
|
96
|
+
* out of scope — an attacker who can do that owns the realm regardless.
|
|
97
|
+
*/
|
|
98
|
+
function sanitizerFor(el) {
|
|
99
|
+
const platform = typeof Element === 'function'
|
|
100
|
+
? Element.prototype.setHTML
|
|
101
|
+
: undefined;
|
|
102
|
+
return typeof platform === 'function'
|
|
103
|
+
&& el.setHTML === platform
|
|
104
|
+
? nativeSetHtml
|
|
105
|
+
: sanitizerFallback;
|
|
106
|
+
}
|
|
107
|
+
function isTrustedHtml(value) {
|
|
108
|
+
const tt = globalThis.trustedTypes;
|
|
109
|
+
return tt !== undefined && tt.isHTML(value);
|
|
110
|
+
}
|
|
111
|
+
// TODO(diagnostics): replace with the core diagnostics leaf (parallel
|
|
112
|
+
// worktree); template-layer codes N101–N107 register there. N105–N107 are
|
|
113
|
+
// thrown (correctness/security guards), unlike the warn-level audit codes.
|
|
114
|
+
function nisliError(code, message) {
|
|
115
|
+
return new Error(`[nisli] ${code}: ${message}`);
|
|
116
|
+
}
|
|
14
117
|
// ── Template cache ──────────────────────────────────────────────────
|
|
15
118
|
/**
|
|
16
119
|
* Cache parsed templates by their static string parts.
|
|
@@ -18,6 +121,67 @@ import { isRef } from './ref.js';
|
|
|
18
121
|
* reference per call site, we use WeakMap for efficient caching.
|
|
19
122
|
*/
|
|
20
123
|
const templateCache = new WeakMap();
|
|
124
|
+
/** Parses performed since module load — test-only regression hook (issue 0015). */
|
|
125
|
+
let parseCount = 0;
|
|
126
|
+
/** @internal Test-only: number of actual `<template>` parses performed. */
|
|
127
|
+
export function __templateParseCount() {
|
|
128
|
+
return parseCount;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Parse-once (ADR 0030.2 T4, issue 0015): build the marker HTML string and
|
|
132
|
+
* parse it into a `<template>` exactly once per callsite, keyed by
|
|
133
|
+
* TemplateStringsArray identity. The string depends only on the static
|
|
134
|
+
* `strings` (marker indices are positional), so every TemplateResult from the
|
|
135
|
+
* same callsite shares one parsed template and clones per mount.
|
|
136
|
+
*
|
|
137
|
+
* The first parse also runs the one-time static audit (template-audit.ts)
|
|
138
|
+
* over the pristine parsed element — it still carries the raw `@event`/
|
|
139
|
+
* `class:`/marker attributes that processAttributes later strips from clones.
|
|
140
|
+
*/
|
|
141
|
+
function parseTemplate(strings, valueCount) {
|
|
142
|
+
let template = templateCache.get(strings);
|
|
143
|
+
if (template)
|
|
144
|
+
return template;
|
|
145
|
+
// Build the HTML string with markers.
|
|
146
|
+
// Track HTML parsing state to auto-quote markers in unquoted
|
|
147
|
+
// attribute positions where the > in --> would close the tag.
|
|
148
|
+
// See ADR 0069.
|
|
149
|
+
let htmlStr = '';
|
|
150
|
+
let inTag = false;
|
|
151
|
+
let quoteChar = null;
|
|
152
|
+
for (let i = 0; i < strings.length; i++) {
|
|
153
|
+
const s = strings[i] ?? '';
|
|
154
|
+
for (let c = 0; c < s.length; c++) {
|
|
155
|
+
const ch = s.charAt(c);
|
|
156
|
+
if (quoteChar) {
|
|
157
|
+
if (ch === quoteChar)
|
|
158
|
+
quoteChar = null;
|
|
159
|
+
}
|
|
160
|
+
else if (inTag) {
|
|
161
|
+
if (ch === '>')
|
|
162
|
+
inTag = false;
|
|
163
|
+
else if (ch === '"' || ch === "'")
|
|
164
|
+
quoteChar = ch;
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
if (ch === '<')
|
|
168
|
+
inTag = true;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
htmlStr += s;
|
|
172
|
+
if (i < valueCount) {
|
|
173
|
+
const needsQuotes = inTag && !quoteChar && /=\s*$/.test(s);
|
|
174
|
+
htmlStr += needsQuotes ? `"${createMarker(i)}"` : createMarker(i);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
parseCount++;
|
|
178
|
+
template = document.createElement('template');
|
|
179
|
+
template.innerHTML = htmlStr;
|
|
180
|
+
templateCache.set(strings, template);
|
|
181
|
+
// Dev-only first-parse audit (N101–N104); no-op when disabled.
|
|
182
|
+
auditTemplate(strings, template);
|
|
183
|
+
return template;
|
|
184
|
+
}
|
|
21
185
|
// ── Marker for expression slots ─────────────────────────────────────
|
|
22
186
|
const MARKER_PREFIX = '<!--bk-';
|
|
23
187
|
const MARKER_SUFFIX = '-->';
|
|
@@ -46,44 +210,29 @@ function createMarker(index) {
|
|
|
46
210
|
export function html(strings, ...values) {
|
|
47
211
|
const bindings = [];
|
|
48
212
|
const disposers = [];
|
|
213
|
+
// Dev-only: remember the authoring callsite for first-parse audit
|
|
214
|
+
// diagnostics — by first-mount time this frame is gone from the stack.
|
|
215
|
+
captureCallsite(strings);
|
|
216
|
+
let mounted = false;
|
|
49
217
|
return {
|
|
50
218
|
__templateResult: true,
|
|
51
219
|
mount(host) {
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
for (let c = 0; c < s.length; c++) {
|
|
62
|
-
const ch = s.charAt(c);
|
|
63
|
-
if (quoteChar) {
|
|
64
|
-
if (ch === quoteChar)
|
|
65
|
-
quoteChar = null;
|
|
66
|
-
}
|
|
67
|
-
else if (inTag) {
|
|
68
|
-
if (ch === '>')
|
|
69
|
-
inTag = false;
|
|
70
|
-
else if (ch === '"' || ch === "'")
|
|
71
|
-
quoteChar = ch;
|
|
72
|
-
}
|
|
73
|
-
else {
|
|
74
|
-
if (ch === '<')
|
|
75
|
-
inTag = true;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
htmlStr += s;
|
|
79
|
-
if (i < values.length) {
|
|
80
|
-
const needsQuotes = inTag && !quoteChar && /=\s*$/.test(s);
|
|
81
|
-
htmlStr += needsQuotes ? `"${createMarker(i)}"` : createMarker(i);
|
|
82
|
-
}
|
|
220
|
+
// Single-mount guard (ADR 0030.2 T4 + §8): a second mount of a live,
|
|
221
|
+
// un-disposed result with value bindings would attach a second set of
|
|
222
|
+
// bindings to the same shared arrays — shared-binding corruption.
|
|
223
|
+
// Zero-binding templates (the registry's shared static icon pattern)
|
|
224
|
+
// stay multi-mountable; dispose() → mount() stays legal.
|
|
225
|
+
if (mounted && values.length > 0) {
|
|
226
|
+
throw nisliError('N105', `mount() on an already-mounted TemplateResult with ${values.length} value ` +
|
|
227
|
+
`binding(s) — dispose() it first or create a fresh template per mount. ` +
|
|
228
|
+
`Template starts: ${JSON.stringify((strings[0] ?? '').slice(0, 60))}`);
|
|
83
229
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
230
|
+
mounted = true;
|
|
231
|
+
// Parse-once: the parsed template is cached per callsite; each mount
|
|
232
|
+
// only pays the clone + binding walk. The per-mount processNode walk
|
|
233
|
+
// below REMAINS by design until the ADR 0014-class work — caching
|
|
234
|
+
// removes the *parse*, not the walk (ADR 0030.2 §8).
|
|
235
|
+
const template = parseTemplate(strings, values.length);
|
|
87
236
|
const fragment = template.content.cloneNode(true);
|
|
88
237
|
// Walk the DOM tree and replace markers with bindings
|
|
89
238
|
processNode(fragment, values, bindings, disposers);
|
|
@@ -107,6 +256,7 @@ export function html(strings, ...values) {
|
|
|
107
256
|
}
|
|
108
257
|
}
|
|
109
258
|
bindings.length = 0;
|
|
259
|
+
mounted = false;
|
|
110
260
|
},
|
|
111
261
|
};
|
|
112
262
|
}
|
|
@@ -488,10 +638,14 @@ function bindAttribute(el, name, value, bindings, disposers) {
|
|
|
488
638
|
return;
|
|
489
639
|
}
|
|
490
640
|
// Auto-resolution: framework components get _setProp for custom props
|
|
491
|
-
// (preserves types). Standard HTML attributes (id, style, data-*,
|
|
492
|
-
//
|
|
641
|
+
// (preserves types). Standard HTML attributes (id, style, data-*, aria-*,
|
|
642
|
+
// and — ADR 0030.2 §8 — title/role/tabindex/name) always use setAttribute
|
|
643
|
+
// even on framework components: routed through _setProp they were silently
|
|
644
|
+
// dead (no component declares them as props).
|
|
493
645
|
const isHtmlAttr = name === 'id' || name === 'style'
|
|
494
|
-
|| name === 'slot' || name
|
|
646
|
+
|| name === 'slot' || name === 'title' || name === 'role'
|
|
647
|
+
|| name === 'tabindex' || name === 'name'
|
|
648
|
+
|| name.startsWith('data-') || name.startsWith('aria-');
|
|
495
649
|
const hasPropSetter = !isHtmlAttr && typeof el._setProp === 'function';
|
|
496
650
|
if (hasPropSetter) {
|
|
497
651
|
if (isSignal(value)) {
|
|
@@ -617,30 +771,95 @@ function bindClass(el, className, value, bindings, disposers) {
|
|
|
617
771
|
}
|
|
618
772
|
}
|
|
619
773
|
/**
|
|
620
|
-
* Bind
|
|
774
|
+
* Bind branded HTML content to an element's inner markup.
|
|
621
775
|
*
|
|
622
|
-
*
|
|
623
|
-
* from
|
|
776
|
+
* Never a bare string — a brand is the author's explicit decision about where
|
|
777
|
+
* the markup came from, which is what closes the accidental-XSS-sink class
|
|
778
|
+
* (ADR 0030.2 §3/§8). Three shapes resolve, in the order they are checked:
|
|
624
779
|
*
|
|
625
|
-
*
|
|
626
|
-
*
|
|
780
|
+
* - `raw()` (core owns the `__raw` brand; @nisli/ssg re-exports it):
|
|
781
|
+
* author-asserted trust → today's `innerHTML` sink, unchanged.
|
|
782
|
+
* NEVER wrap user-generated input in raw().
|
|
783
|
+
* - `sanitized()` → the sanitizer sink: native `Element.setHTML()`, else the
|
|
784
|
+
* `setSanitizerFallback()` hook, else N107. It NEVER reaches `innerHTML`
|
|
785
|
+
* (ADR 0019:124–128, bet-09(c)).
|
|
786
|
+
* - A native `TrustedHTML` object → the same `innerHTML` sink as `raw()`,
|
|
787
|
+
* passed through unwrapped so Trusted-Types-enforcing apps can assign
|
|
788
|
+
* policy output directly.
|
|
789
|
+
*
|
|
790
|
+
* Anything else throws N106. Supports both static values and reactive
|
|
791
|
+
* `Signal`s of them; when the signal changes the content is rewritten through
|
|
792
|
+
* the same branch. null/undefined (branded or not) clears the content.
|
|
627
793
|
*
|
|
628
794
|
* Usage in templates:
|
|
629
|
-
* html`<span html:inner="${highlightedHtml}"></span>`
|
|
795
|
+
* html`<span html:inner="${raw(highlightedHtml)}"></span>`
|
|
796
|
+
* html`<span html:inner="${sanitized(userComment)}"></span>`
|
|
630
797
|
*/
|
|
631
798
|
function bindInnerHtml(el, value, bindings, disposers) {
|
|
799
|
+
// PURE — validates the brand and proves a sink exists without writing
|
|
800
|
+
// anything, so the eager signal check below can run it for its throw alone.
|
|
801
|
+
// A SanitizedHtml resolves to ITSELF (the writer routes it to the
|
|
802
|
+
// sanitizer); everything else resolves to the innerHTML payload.
|
|
803
|
+
const resolveHtml = (v) => {
|
|
804
|
+
if (v == null)
|
|
805
|
+
return '';
|
|
806
|
+
if (isRawHtml(v)) {
|
|
807
|
+
// Both brands at once: trust is not resolvable in favour of the raw
|
|
808
|
+
// sink, so reject rather than guess which one the author meant.
|
|
809
|
+
if (isSanitizedHtml(v)) {
|
|
810
|
+
throw nisliError('N106', `html:inner rejects a value branded both raw() and sanitized() — the ` +
|
|
811
|
+
`trust decision is ambiguous. Pick one brand.`);
|
|
812
|
+
}
|
|
813
|
+
return v.value;
|
|
814
|
+
}
|
|
815
|
+
if (isSanitizedHtml(v)) {
|
|
816
|
+
// FAIL CLOSED. Untrusted markup is only ever eligible to be written once
|
|
817
|
+
// a sanitizing sink is proven to exist; no native setHTML() and no
|
|
818
|
+
// registered hook is a hard error, because silently downgrading to
|
|
819
|
+
// innerHTML would reopen the exact XSS hole this brand closes.
|
|
820
|
+
if (sanitizerFor(el) === null) {
|
|
821
|
+
throw nisliError('N107', `html:inner cannot render sanitized() markup: this engine has no native ` +
|
|
822
|
+
`Element.setHTML() and no fallback is registered. Wire one at startup — ` +
|
|
823
|
+
`setSanitizerFallback((el, html) => { el.innerHTML = DOMPurify.sanitize(html) }) ` +
|
|
824
|
+
`— nisli ships no sanitizer and will not fall back to innerHTML.`);
|
|
825
|
+
}
|
|
826
|
+
return v;
|
|
827
|
+
}
|
|
828
|
+
if (isTrustedHtml(v))
|
|
829
|
+
return v;
|
|
830
|
+
throw nisliError('N106', `html:inner rejects unbranded values (got ${typeof v}). Wrap trusted markup ` +
|
|
831
|
+
`in raw(html), untrusted markup in sanitized(html) — and never wrap ` +
|
|
832
|
+
`user-generated input in raw().`);
|
|
833
|
+
};
|
|
834
|
+
const writeHtml = (v) => {
|
|
835
|
+
const resolved = resolveHtml(v);
|
|
836
|
+
if (isSanitizedHtml(resolved)) {
|
|
837
|
+
// The sanitizer sink. resolveHtml has already proven a writer exists,
|
|
838
|
+
// and `el.innerHTML` is unreachable from this branch by construction.
|
|
839
|
+
sanitizerFor(el)?.(el, resolved.value);
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
// The raw sink, unchanged: a plain string, or a TrustedHTML object handed
|
|
843
|
+
// over WITHOUT stringifying it (stringifying would defeat the CSP check).
|
|
844
|
+
// The cast only satisfies the DOM library's `string` typing.
|
|
845
|
+
el.innerHTML = resolved;
|
|
846
|
+
};
|
|
632
847
|
if (isSignal(value)) {
|
|
848
|
+
// Validate the INITIAL value untracked and eagerly so an unbranded or
|
|
849
|
+
// unsanitizable signal throws synchronously out of mount(); later invalid
|
|
850
|
+
// values throw inside the effect and land in effect error containment
|
|
851
|
+
// (console.error, element content untouched).
|
|
852
|
+
untracked(() => resolveHtml(value.value));
|
|
633
853
|
const binding = { type: 'innerHtml', element: el };
|
|
634
854
|
bindings.push(binding);
|
|
635
855
|
const dispose = effect(() => {
|
|
636
|
-
|
|
637
|
-
el.innerHTML = v == null ? '' : String(v);
|
|
856
|
+
writeHtml(value.value);
|
|
638
857
|
});
|
|
639
858
|
binding.dispose = dispose;
|
|
640
859
|
disposers.push(dispose);
|
|
641
860
|
}
|
|
642
861
|
else {
|
|
643
|
-
|
|
862
|
+
writeHtml(value);
|
|
644
863
|
}
|
|
645
864
|
}
|
|
646
865
|
function bindEvent(el, eventName, handler, modifiers, bindings) {
|
|
@@ -693,9 +912,13 @@ function bindEvent(el, eventName, handler, modifiers, bindings) {
|
|
|
693
912
|
}
|
|
694
913
|
}
|
|
695
914
|
// Wrap in try/catch for error containment — THIS is the installed handler.
|
|
915
|
+
// Handler bodies run UNTRACKED (ADR 0030.2 §8, surfaced by the T5 review):
|
|
916
|
+
// synchronous dispatchEvent from inside an effect would otherwise run the
|
|
917
|
+
// handler under that effect's observer — widening the effect's dependency
|
|
918
|
+
// set with every signal the handler reads and mis-attributing its writes.
|
|
696
919
|
safeHandler = (e) => {
|
|
697
920
|
try {
|
|
698
|
-
composed(e);
|
|
921
|
+
untrack(() => composed(e));
|
|
699
922
|
}
|
|
700
923
|
catch (err) {
|
|
701
924
|
console.error(`Event handler error for '${eventName}':`, err);
|
|
@@ -716,17 +939,6 @@ function bindEvent(el, eventName, handler, modifiers, bindings) {
|
|
|
716
939
|
dispose: () => el.removeEventListener(eventName, safeHandler),
|
|
717
940
|
});
|
|
718
941
|
}
|
|
719
|
-
/**
|
|
720
|
-
* Conditional rendering helper.
|
|
721
|
-
* Shows the template when condition is truthy.
|
|
722
|
-
*
|
|
723
|
-
* Supports both static and reactive (signal) conditions.
|
|
724
|
-
* For signal conditions, returns a computed that reactively switches
|
|
725
|
-
* between the template and null.
|
|
726
|
-
*
|
|
727
|
-
* The template argument can be a TemplateResult or a lazy callback
|
|
728
|
-
* `() => TemplateResult` to avoid evaluating expensive branches.
|
|
729
|
-
*/
|
|
730
942
|
// ── each() reactive list rendering ──────────────────────────────────
|
|
731
943
|
/** Brand for each result detection */
|
|
732
944
|
const EACH_BRAND = Symbol.for('backlog.each');
|
|
@@ -744,6 +956,32 @@ const EACH_BRAND = Symbol.for('backlog.each');
|
|
|
744
956
|
* )}</ul>`
|
|
745
957
|
* ```
|
|
746
958
|
*/
|
|
959
|
+
/**
|
|
960
|
+
* Adapt whatever a template callback returned into a mountable TemplateResult.
|
|
961
|
+
* Factory descriptors (the value `component()` factories actually return) are
|
|
962
|
+
* mounted through the same path every other slot uses (issue 0021).
|
|
963
|
+
*/
|
|
964
|
+
function asTemplateResult(result) {
|
|
965
|
+
if (result && typeof result === 'object' && '__templateResult' in result) {
|
|
966
|
+
return result;
|
|
967
|
+
}
|
|
968
|
+
if (result && typeof result === 'object' && result.__type === 'factory') {
|
|
969
|
+
let mounted = null;
|
|
970
|
+
return {
|
|
971
|
+
__templateResult: true,
|
|
972
|
+
mount(host) {
|
|
973
|
+
mounted = mountFactoryResult(result);
|
|
974
|
+
host.appendChild(mounted.element);
|
|
975
|
+
},
|
|
976
|
+
dispose() {
|
|
977
|
+
mounted?.dispose();
|
|
978
|
+
mounted?.element.remove();
|
|
979
|
+
mounted = null;
|
|
980
|
+
},
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
throw new TypeError(`each(): template callback must return a TemplateResult or a component factory result, got ${result === null ? 'null' : typeof result}`);
|
|
984
|
+
}
|
|
747
985
|
export function each(items, keyFn, templateFn) {
|
|
748
986
|
let startMarker;
|
|
749
987
|
let endMarker;
|
|
@@ -822,7 +1060,7 @@ export function each(items, keyFn, templateFn) {
|
|
|
822
1060
|
const indexSignal = signal(index);
|
|
823
1061
|
const wrapper = document.createElement('each-item');
|
|
824
1062
|
wrapper.style.display = 'contents';
|
|
825
|
-
const templateResult = templateFn(itemSignal, indexSignal);
|
|
1063
|
+
const templateResult = asTemplateResult(templateFn(itemSignal, indexSignal));
|
|
826
1064
|
templateResult.mount(wrapper);
|
|
827
1065
|
newEntries.push({ key, itemSignal, indexSignal, templateResult, wrapper });
|
|
828
1066
|
}
|
|
@@ -841,20 +1079,65 @@ export function each(items, keyFn, templateFn) {
|
|
|
841
1079
|
for (const entry of newEntries) {
|
|
842
1080
|
const nextSibling = cursor.nextSibling;
|
|
843
1081
|
if (nextSibling !== entry.wrapper) {
|
|
844
|
-
|
|
1082
|
+
if (entry.wrapper.parentNode === parent && canMoveWithin(parent)) {
|
|
1083
|
+
// Already attached under this parent: atomic move preconditions hold.
|
|
1084
|
+
parent.moveBefore(entry.wrapper, nextSibling);
|
|
1085
|
+
}
|
|
1086
|
+
else {
|
|
1087
|
+
// Fresh wrapper, or a browser without moveBefore().
|
|
1088
|
+
parent.insertBefore(entry.wrapper, nextSibling);
|
|
1089
|
+
}
|
|
845
1090
|
}
|
|
846
1091
|
cursor = entry.wrapper;
|
|
847
1092
|
}
|
|
848
1093
|
entries = newEntries;
|
|
849
1094
|
}
|
|
850
1095
|
}
|
|
851
|
-
|
|
852
|
-
|
|
1096
|
+
/**
|
|
1097
|
+
* Conditional rendering helper (ADR 0030.2 §3, boolean-gated with `else`).
|
|
1098
|
+
*
|
|
1099
|
+
* Shows `template` when the condition is truthy, `elseTemplate` (optional)
|
|
1100
|
+
* when falsy. Both arms accept a TemplateResult or a lazy callback
|
|
1101
|
+
* `() => TemplateResult`; callbacks are only evaluated for the active arm.
|
|
1102
|
+
*
|
|
1103
|
+
* Semantics for signal conditions:
|
|
1104
|
+
* - The gate is `!!condition.value` — a truthy→truthy (or falsy→falsy)
|
|
1105
|
+
* transition returns the memoized previous result, so the live branch is
|
|
1106
|
+
* NEVER rebuilt (no state/scroll/focus loss) and callbacks never re-run.
|
|
1107
|
+
* - Branch callbacks evaluate UNTRACKED: a signal read during branch
|
|
1108
|
+
* construction does not subscribe the when() computed, so it cannot
|
|
1109
|
+
* trigger a rebuild. Reactive parts inside a branch must be interpolated
|
|
1110
|
+
* as signals/computeds (values), not read raw at construction time.
|
|
1111
|
+
*/
|
|
1112
|
+
export function when(condition, template, elseTemplate) {
|
|
1113
|
+
const resolveBranch = (branch) => {
|
|
1114
|
+
if (branch === undefined)
|
|
1115
|
+
return null;
|
|
1116
|
+
if (typeof branch === 'function')
|
|
1117
|
+
return untracked(branch);
|
|
1118
|
+
return branch;
|
|
1119
|
+
};
|
|
853
1120
|
if (isSignal(condition)) {
|
|
854
|
-
//
|
|
855
|
-
|
|
1121
|
+
// Boolean-gated memo: notify() marks observers dirty EAGERLY (before
|
|
1122
|
+
// value-equality is known), so a computed chain alone cannot stop a
|
|
1123
|
+
// truthy→truthy recompute from re-evaluating the branch. The memo pins
|
|
1124
|
+
// branch evaluation to actual boolean FLIPS; equal gates return the
|
|
1125
|
+
// cached result, which the slot then memoizes by identity (ADR 0008.1).
|
|
1126
|
+
let lastGate;
|
|
1127
|
+
let lastResult = null;
|
|
1128
|
+
return computed(() => {
|
|
1129
|
+
const gate = !!condition.value;
|
|
1130
|
+
if (gate === lastGate)
|
|
1131
|
+
return lastResult;
|
|
1132
|
+
const next = gate ? resolveBranch(template) : resolveBranch(elseTemplate);
|
|
1133
|
+
// Commit the memo only after a successful resolve so a throwing branch
|
|
1134
|
+
// callback retries on the next read (computed error containment).
|
|
1135
|
+
lastGate = gate;
|
|
1136
|
+
lastResult = next;
|
|
1137
|
+
return next;
|
|
1138
|
+
});
|
|
856
1139
|
}
|
|
857
|
-
return condition ?
|
|
1140
|
+
return condition ? resolveBranch(template) : resolveBranch(elseTemplate);
|
|
858
1141
|
}
|
|
859
1142
|
/**
|
|
860
1143
|
* Build a DOM element by a (possibly *dynamic*) tag name with reactive
|