@webjsdev/core 0.7.10 → 0.7.12

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/index-browser.js CHANGED
@@ -44,10 +44,14 @@ export {
44
44
  } from './src/serialize.js';
45
45
  export { enableClientRouter, disableClientRouter, navigate, revalidate } from './src/router-client.js';
46
46
  export { WebjsFrame } from './src/webjs-frame.js';
47
+ export { WebjsStream, renderStream } from './src/webjs-stream.js';
47
48
 
48
49
  // Signals (TC39 Stage-1 shape), also available via '@webjsdev/core/signals'
49
50
  export { signal, computed, effect, batch, isSignal, Signal } from './src/signal.js';
50
51
 
52
+ // Optimistic-mutation helper (thin signal wrapper, rolls back on failure)
53
+ export { optimistic } from './src/optimistic.js';
54
+
51
55
  // Directives, also available via '@webjsdev/core/directives'. The full
52
56
  // lit-html-parity set is re-exported here so the dist browser bundle (which
53
57
  // the `@webjsdev/core/directives` subpath collapses onto in dist mode) carries
package/index.d.ts CHANGED
@@ -77,3 +77,12 @@ export function navigate(url: Route, opts?: { replace?: boolean }): Promise<void
77
77
  export { unsafeHTML, isUnsafeHTML, live, isLive } from './src/directives.js';
78
78
  export { createContext, ContextProvider, ContextConsumer, ContextRequestEvent } from './src/context.js';
79
79
  export { Task, TaskStatus } from './src/task.js';
80
+
81
+ // Optimistic-mutation helper: set a signal to an expected value immediately,
82
+ // run the action, roll back on a thrown error or a `{ success: false }`
83
+ // ActionResult, keep the value on success. Returns the action's result.
84
+ export function optimistic<T, R>(
85
+ signal: { get(): T; set(v: T): void },
86
+ value: T,
87
+ action: () => Promise<R> | R,
88
+ ): Promise<R>;
package/index.js CHANGED
@@ -26,10 +26,14 @@ export {
26
26
  } from './src/serialize.js';
27
27
  export { enableClientRouter, disableClientRouter, navigate, revalidate } from './src/router-client.js';
28
28
  export { WebjsFrame } from './src/webjs-frame.js';
29
+ export { WebjsStream, renderStream } from './src/webjs-stream.js';
29
30
 
30
31
  // Signals (TC39 Stage-1 shape), also available via '@webjsdev/core/signals'
31
32
  export { signal, computed, effect, batch, isSignal, Signal } from './src/signal.js';
32
33
 
34
+ // Optimistic-mutation helper (thin signal wrapper, rolls back on failure)
35
+ export { optimistic } from './src/optimistic.js';
36
+
33
37
  // Directives, also available via '@webjsdev/core/directives'. The full
34
38
  // lit-html-parity set is re-exported so the bare specifier exposes the same
35
39
  // directive surface in Node as the browser bundle does.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/core",
3
- "version": "0.7.10",
3
+ "version": "0.7.12",
4
4
  "type": "module",
5
5
  "description": "webjs core runtime - html/css tags, WebComponent base, isomorphic renderers",
6
6
  "types": "./index.d.ts",
@@ -11,37 +11,55 @@
11
11
  "default": "./dist/webjs-core.js"
12
12
  },
13
13
  "./directives": {
14
+ "types": "./src/directives.d.ts",
14
15
  "source": "./src/directives.js",
15
16
  "default": "./dist/webjs-core-browser.js"
16
17
  },
17
18
  "./context": {
19
+ "types": "./src/context.d.ts",
18
20
  "source": "./src/context.js",
19
21
  "default": "./dist/webjs-core-browser.js"
20
22
  },
21
23
  "./task": {
24
+ "types": "./src/task.d.ts",
22
25
  "source": "./src/task.js",
23
26
  "default": "./dist/webjs-core-browser.js"
24
27
  },
25
28
  "./client-router": {
29
+ "types": "./src/router-client.d.ts",
26
30
  "source": "./src/router-client.js",
27
31
  "default": "./dist/webjs-core-browser.js"
28
32
  },
29
33
  "./lazy-loader": {
34
+ "types": "./src/lazy-loader.d.ts",
30
35
  "source": "./src/lazy-loader.js",
31
36
  "default": "./dist/webjs-core-lazy-loader.js"
32
37
  },
33
38
  "./testing": {
39
+ "types": "./src/testing.d.ts",
34
40
  "source": "./src/testing.js",
35
41
  "default": "./dist/webjs-core-testing.js"
36
42
  },
37
- "./client": "./src/render-client.js",
38
- "./server": "./src/render-server.js",
43
+ "./client": {
44
+ "types": "./src/render-client.d.ts",
45
+ "default": "./src/render-client.js"
46
+ },
47
+ "./server": {
48
+ "types": "./src/render-server.d.ts",
49
+ "default": "./src/render-server.js"
50
+ },
39
51
  "./component": {
40
52
  "types": "./src/component.d.ts",
41
53
  "default": "./src/component.js"
42
54
  },
43
- "./registry": "./src/registry.js",
44
- "./signals": "./src/signal.js",
55
+ "./registry": {
56
+ "types": "./src/registry.d.ts",
57
+ "default": "./src/registry.js"
58
+ },
59
+ "./signals": {
60
+ "types": "./src/signal.d.ts",
61
+ "default": "./src/signal.js"
62
+ },
45
63
  "./package.json": "./package.json"
46
64
  },
47
65
  "files": [
@@ -0,0 +1,34 @@
1
+ import type { ReactiveController, WebComponent } from './component.js';
2
+
3
+ export interface Context<T = unknown> {
4
+ __context__: symbol;
5
+ name: string;
6
+ }
7
+
8
+ export function createContext<T = unknown>(name: string): Context<T>;
9
+
10
+ export class ContextRequestEvent<T = unknown> extends Event {
11
+ context: Context<T>;
12
+ callback: (value: T, unsubscribe?: () => void) => void;
13
+ subscribe: boolean;
14
+ constructor(
15
+ context: Context<T>,
16
+ callback: (value: T, unsubscribe?: () => void) => void,
17
+ subscribe?: boolean,
18
+ );
19
+ }
20
+
21
+ export class ContextProvider<T = unknown> implements ReactiveController {
22
+ constructor(host: WebComponent, options: { context: Context<T>; initialValue?: T });
23
+ get value(): T;
24
+ setValue(newValue: T): void;
25
+ hostConnected(): void;
26
+ hostDisconnected(): void;
27
+ }
28
+
29
+ export class ContextConsumer<T = unknown> implements ReactiveController {
30
+ constructor(host: WebComponent, options: { context: Context<T>; subscribe?: boolean });
31
+ get value(): T | undefined;
32
+ hostConnected(): void;
33
+ hostDisconnected(): void;
34
+ }
@@ -0,0 +1,98 @@
1
+ export { repeat, isRepeat } from './repeat.js';
2
+
3
+ export interface UnsafeHTMLDirective {
4
+ _$webjs: 'unsafe-html';
5
+ value: string;
6
+ }
7
+
8
+ export interface LiveDirective<T = unknown> {
9
+ _$webjs: 'live';
10
+ value: T;
11
+ }
12
+
13
+ export interface KeyedDirective<T = unknown> {
14
+ _$webjs: 'keyed';
15
+ key: unknown;
16
+ value: T;
17
+ }
18
+
19
+ export interface GuardDirective<T = unknown> {
20
+ _$webjs: 'guard';
21
+ deps: readonly unknown[];
22
+ fn: () => T;
23
+ }
24
+
25
+ export interface TemplateContentDirective {
26
+ _$webjs: 'template-content';
27
+ template: HTMLTemplateElement | { innerHTML?: string; content?: DocumentFragment };
28
+ }
29
+
30
+ export interface RefObject<T extends Element = Element> {
31
+ value: T | undefined;
32
+ }
33
+
34
+ export interface RefDirective<T extends Element = Element> {
35
+ _$webjs: 'ref';
36
+ target: RefObject<T> | ((el: T | undefined) => void);
37
+ }
38
+
39
+ export interface CacheDirective<T = unknown> {
40
+ _$webjs: 'cache';
41
+ value: T;
42
+ }
43
+
44
+ export interface UntilDirective {
45
+ _$webjs: 'until';
46
+ args: unknown[];
47
+ }
48
+
49
+ export interface AsyncAppendDirective<T = unknown> {
50
+ _$webjs: 'async-append';
51
+ iterable: AsyncIterable<T>;
52
+ mapper?: (value: T, index: number) => unknown;
53
+ }
54
+
55
+ export interface AsyncReplaceDirective<T = unknown> {
56
+ _$webjs: 'async-replace';
57
+ iterable: AsyncIterable<T>;
58
+ mapper?: (value: T, index: number) => unknown;
59
+ }
60
+
61
+ export interface WatchDirective<T = unknown> {
62
+ _$webjs: 'watch';
63
+ signal: { get(): T; __isSignal: true };
64
+ }
65
+
66
+ export function unsafeHTML(htmlString: string | null | undefined): UnsafeHTMLDirective;
67
+ export function isUnsafeHTML(x: unknown): x is UnsafeHTMLDirective;
68
+ export function live<T>(value: T): LiveDirective<T>;
69
+ export function isLive(x: unknown): x is LiveDirective;
70
+ export function keyed<T>(key: unknown, template: T): KeyedDirective<T>;
71
+ export function isKeyed(x: unknown): x is KeyedDirective;
72
+ export function guard<T>(deps: readonly unknown[], fn: () => T): GuardDirective<T>;
73
+ export function isGuard(x: unknown): x is GuardDirective;
74
+ export function templateContent(
75
+ template: HTMLTemplateElement | { innerHTML?: string; content?: DocumentFragment },
76
+ ): TemplateContentDirective;
77
+ export function isTemplateContent(x: unknown): x is TemplateContentDirective;
78
+ export function ref<T extends Element = Element>(
79
+ refOrCallback: RefObject<T> | ((el: T | undefined) => void),
80
+ ): RefDirective<T>;
81
+ export function isRef(x: unknown): x is RefDirective;
82
+ export function createRef<T extends Element = Element>(): RefObject<T>;
83
+ export function cache<T>(value: T): CacheDirective<T>;
84
+ export function isCache(x: unknown): x is CacheDirective;
85
+ export function until(...args: unknown[]): UntilDirective;
86
+ export function isUntil(x: unknown): x is UntilDirective;
87
+ export function asyncAppend<T>(
88
+ iterable: AsyncIterable<T>,
89
+ mapper?: (value: T, index: number) => unknown,
90
+ ): AsyncAppendDirective<T>;
91
+ export function isAsyncAppend(x: unknown): x is AsyncAppendDirective;
92
+ export function asyncReplace<T>(
93
+ iterable: AsyncIterable<T>,
94
+ mapper?: (value: T, index: number) => unknown,
95
+ ): AsyncReplaceDirective<T>;
96
+ export function isAsyncReplace(x: unknown): x is AsyncReplaceDirective;
97
+ export function watch<T>(sig: { get(): T; __isSignal: true }): WatchDirective<T>;
98
+ export function isWatch(x: unknown): x is WatchDirective;
@@ -0,0 +1,2 @@
1
+ export function observeLazy(entries: Record<string, string>): void;
2
+ export function observeViewportOnce(el: Element, callback: () => void): () => void;
@@ -105,3 +105,59 @@ function onIntersect(entries) {
105
105
  observer?.unobserve(entry.target);
106
106
  }
107
107
  }
108
+
109
+ /* ====================================================================
110
+ * Per-element viewport observation (shared with <webjs-frame loading="lazy">)
111
+ * ==================================================================== */
112
+
113
+ /**
114
+ * A separate IntersectionObserver keyed by ELEMENT (not by tag) so a single
115
+ * element can fire a one-shot callback when it scrolls into view. Reuses the
116
+ * same `rootMargin: '200px'` warm-up budget as the tag-based lazy-component
117
+ * observer above, so a `<webjs-frame loading="lazy">` self-loads with the same
118
+ * timing as a `static lazy = true` component module.
119
+ *
120
+ * @type {IntersectionObserver | null}
121
+ */
122
+ let elementObserver = null;
123
+
124
+ /** @type {WeakMap<Element, () => void>} element → its one-shot callback */
125
+ const elementCallbacks = new WeakMap();
126
+
127
+ /** @param {IntersectionObserverEntry[]} entries */
128
+ function onElementIntersect(entries) {
129
+ for (const entry of entries) {
130
+ if (!entry.isIntersecting) continue;
131
+ const cb = elementCallbacks.get(entry.target);
132
+ elementCallbacks.delete(entry.target);
133
+ elementObserver?.unobserve(entry.target);
134
+ if (cb) cb();
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Fire `callback` once when `el` first enters the viewport (within the
140
+ * shared 200px margin). When IntersectionObserver is unavailable (very old
141
+ * browser, or a non-DOM test env) the callback fires immediately, so a lazy
142
+ * frame still self-loads rather than staying blank. Returns a teardown that
143
+ * stops observing (the frame calls it on disconnect / a `src` change).
144
+ *
145
+ * @param {Element} el
146
+ * @param {() => void} callback
147
+ * @returns {() => void} Teardown that unobserves `el`.
148
+ */
149
+ export function observeViewportOnce(el, callback) {
150
+ if (typeof IntersectionObserver === 'undefined') {
151
+ callback();
152
+ return () => {};
153
+ }
154
+ if (!elementObserver) {
155
+ elementObserver = new IntersectionObserver(onElementIntersect, { rootMargin: '200px' });
156
+ }
157
+ elementCallbacks.set(el, callback);
158
+ elementObserver.observe(el);
159
+ return () => {
160
+ elementCallbacks.delete(el);
161
+ elementObserver?.unobserve(el);
162
+ };
163
+ }
package/src/nav.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ export function notFound(): never;
2
+ export function redirect(url: string, status?: number): never;
3
+ export function isNotFound(e: unknown): boolean;
4
+ export function isRedirect(e: unknown): boolean;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * `optimistic(signal, value, action)`, a thin optimistic-mutation helper.
3
+ *
4
+ * The optimistic-UI pattern: show the expected result of a mutation
5
+ * IMMEDIATELY (so the interface feels instant), run the real server action,
6
+ * and roll the optimistic value back if the action fails. This wrapper does
7
+ * exactly that and nothing more, no state machine, no extra reactivity, just
8
+ * the existing signal primitive plus a try / envelope check.
9
+ *
10
+ * import { signal, optimistic } from '@webjsdev/core';
11
+ * import { likePost } from '../actions/like-post.server.js';
12
+ *
13
+ * const liked = signal(false);
14
+ * // In an @click handler:
15
+ * await optimistic(liked, true, () => likePost(postId));
16
+ * // `liked` flips to true instantly; if likePost throws or returns
17
+ * // { success: false }, it rolls back to its prior value.
18
+ *
19
+ * Behaviour:
20
+ * 1. Capture `prev = signal.get()`.
21
+ * 2. `signal.set(value)` so the UI updates before the round-trip.
22
+ * 3. `await action()`.
23
+ * 4a. The action THROWS -> `signal.set(prev)` (rollback) + re-throw.
24
+ * 4b. The action returns an ActionResult FAILURE envelope
25
+ * (`result && result.success === false`) -> `signal.set(prev)`
26
+ * (rollback) + return the result (the caller reads its
27
+ * `error` / `fieldErrors`).
28
+ * 4c. SUCCESS (anything else) -> keep the optimistic value + return the
29
+ * result. The caller can reconcile to the authoritative value from the
30
+ * returned result if it wants to (e.g. `signal.set(result.data.count)`).
31
+ *
32
+ * It rolls back on a thrown error OR a `{ success: false }` ActionResult, and
33
+ * never rolls back on success. Client-only: it mutates a signal (client work),
34
+ * so a component importing it is never elided as display-only.
35
+ *
36
+ * @template T
37
+ * @param {{ get: () => T, set: (v: T) => void }} signal A webjs signal.
38
+ * @param {T} value The optimistic value to show immediately.
39
+ * @param {() => Promise<any> | any} action The server-action call (or any
40
+ * thunk returning a Promise / value).
41
+ * @returns {Promise<any>} The action's result. On a `{ success: false }`
42
+ * envelope the rolled-back result is returned (not thrown); a thrown action
43
+ * re-throws after rollback.
44
+ */
45
+ export async function optimistic(signal, value, action) {
46
+ const prev = signal.get();
47
+ signal.set(value);
48
+ let result;
49
+ try {
50
+ result = await action();
51
+ } catch (err) {
52
+ // The action rejected: roll the optimistic value back and let the caller
53
+ // handle the error (re-throw, do not swallow).
54
+ signal.set(prev);
55
+ throw err;
56
+ }
57
+ // ActionResult FAILURE envelope: a `{ success: false }` is a handled
58
+ // failure, not a throw. Roll back and hand the result back so the caller
59
+ // can read its `error` / `fieldErrors`.
60
+ if (result && result.success === false) {
61
+ signal.set(prev);
62
+ }
63
+ return result;
64
+ }
@@ -0,0 +1,11 @@
1
+ import type { WebComponent } from './component.js';
2
+
3
+ export type WebComponentConstructor = typeof WebComponent;
4
+
5
+ export function register(tag: string, cls: WebComponentConstructor): void;
6
+ export function primeModuleUrl(tag: string, moduleUrl: string): void;
7
+ export function lookup(tag: string): WebComponentConstructor | undefined;
8
+ export function lookupModuleUrl(tag: string): string | undefined;
9
+ export function isLazy(tag: string): boolean;
10
+ export function tagOf(cls: WebComponentConstructor): string | undefined;
11
+ export function allTags(): string[];
@@ -0,0 +1 @@
1
+ export function render(value: unknown, container: Element | DocumentFragment | ShadowRoot): void;
@@ -37,8 +37,12 @@ import {
37
37
  * - Event listeners are attached once and retargeted when the handler
38
38
  * reference changes (swap-in-place via a dispatch closure, so `addEventListener`
39
39
  * isn't churned every render).
40
- * - This is a conservative implementation; no keyed list diffing yet: array
41
- * changes rebuild the whole text part.
40
+ * - A plain `.map()` array reconciles POSITIONALLY (non-keyed), matching
41
+ * lit-html: each index updates its item instance in place when the
42
+ * template shape is unchanged, so DOM node identity (and the focus,
43
+ * selection, scroll, and in-progress native drag it carries) survives an
44
+ * item-level update. See `reconcileArray`. Keyed reordering still needs
45
+ * the `repeat()` directive.
42
46
  */
43
47
 
44
48
  /** @type {WeakMap<TemplateStringsArray | string[], { templateEl: HTMLTemplateElement, parts: PartDescriptor[] }>} */
@@ -911,12 +915,35 @@ function applyChildInner(part, value) {
911
915
  return;
912
916
  }
913
917
 
918
+ // Plain array (a `.map()` / list interpolation): positional, non-keyed
919
+ // reconciliation. Update each position's instance IN PLACE when its
920
+ // template shape is unchanged, so DOM node identity survives an item
921
+ // update (focus, selection, scroll, and an in-progress native drag all
922
+ // survive). Mirrors lit-html's non-keyed array child part. Without this,
923
+ // flipping one item's binding rebuilt the WHOLE list and detached every
924
+ // node, which cancels a native drag mid-gesture. Use `repeat()` for keyed
925
+ // reordering.
926
+ if (Array.isArray(value)) {
927
+ if (part.child && /** @type any */ (part.child).kind === 'array') {
928
+ reconcileArray(part, value);
929
+ return;
930
+ }
931
+ teardownChild(part);
932
+ const arrState = { kind: 'array', items: [] };
933
+ part.child = arrState;
934
+ applyArrayFresh(marker, /** @type any */ (arrState), value);
935
+ return;
936
+ }
937
+
914
938
  // Remove previously rendered nodes between marker and its next sibling we own.
915
939
  if (part.child) {
916
940
  const c = /** @type any */ (part.child);
917
941
  if (c.kind === 'repeat') {
918
942
  teardownRepeat(c);
919
943
  part.child = undefined;
944
+ } else if (c.kind === 'array') {
945
+ teardownArray(/** @type any */ (c));
946
+ part.child = undefined;
920
947
  } else if (c.kind === 'async-stream') {
921
948
  teardownAsyncStream(c);
922
949
  part.child = undefined;
@@ -966,29 +993,6 @@ function applyChildInner(part, value) {
966
993
  return;
967
994
  }
968
995
 
969
- if (Array.isArray(value)) {
970
- const list = [];
971
- for (const v of value) {
972
- if (isTemplate(v)) {
973
- // Create an inline instance. No keyed reconciliation yet.
974
- const tr = /** @type any */ (v);
975
- const { templateEl, parts } = compile(tr);
976
- const frag = /** @type DocumentFragment */ (templateEl.content.cloneNode(true));
977
- const bound = parts.map((p) => bindPart(p, frag));
978
- for (let i = 0; i < tr.values.length; i++) {
979
- applyPart(bound[i], tr.values[i], undefined, tr.values);
980
- }
981
- list.push(...frag.childNodes);
982
- } else if (v != null && v !== false && v !== true) {
983
- list.push(document.createTextNode(String(v)));
984
- }
985
- }
986
- const frag = nodesToFrag(list);
987
- marker.parentNode?.insertBefore(frag, marker);
988
- part.child = list;
989
- return;
990
- }
991
-
992
996
  const node = document.createTextNode(String(value));
993
997
  marker.parentNode?.insertBefore(node, marker);
994
998
  part.child = [node];
@@ -1150,6 +1154,151 @@ function teardownRepeat(state) {
1150
1154
  state.map.clear();
1151
1155
  }
1152
1156
 
1157
+ /* ================================================================
1158
+ * Plain array (.map) support: positional, non-keyed reconciliation
1159
+ * ================================================================ */
1160
+
1161
+ /**
1162
+ * One rendered slot of a plain array. A `tpl` carries a detached template
1163
+ * instance (bookended by its own markers), a `text` carries a single text
1164
+ * node, and an `empty` slot (a nullish / boolean element) renders nothing
1165
+ * but still holds the position so index-based reconciliation stays aligned.
1166
+ * @typedef {{ type: 'tpl', inst: TemplateInstance } | { type: 'text', node: Text } | { type: 'empty' }} ArrayItem
1167
+ */
1168
+
1169
+ /** @param {ArrayItem} item @returns {ChildNode | null} */
1170
+ function arrayItemFirstNode(item) {
1171
+ if (item.type === 'tpl') return item.inst.startNode;
1172
+ if (item.type === 'text') return item.node;
1173
+ return null;
1174
+ }
1175
+
1176
+ /** @param {ArrayItem} item */
1177
+ function removeArrayItem(item) {
1178
+ if (item.type === 'tpl') {
1179
+ disposeInstance(item.inst);
1180
+ removeBetween(item.inst.startNode, item.inst.endNode);
1181
+ } else if (item.type === 'text') {
1182
+ item.node.parentNode?.removeChild(item.node);
1183
+ }
1184
+ }
1185
+
1186
+ /**
1187
+ * Build the slot for one array element, plus the fragment to insert (null
1188
+ * for an empty slot). A TemplateResult becomes a detached instance, a
1189
+ * primitive becomes a text node, and nullish / boolean renders nothing.
1190
+ * @param {unknown} v
1191
+ * @returns {{ item: ArrayItem, frag: Node | null }}
1192
+ */
1193
+ function buildArrayItem(v) {
1194
+ if (isTemplate(v)) {
1195
+ const { inst, frag } = buildDetached(/** @type any */ (v));
1196
+ return { item: { type: 'tpl', inst }, frag };
1197
+ }
1198
+ if (v != null && v !== false && v !== true) {
1199
+ const node = document.createTextNode(String(v));
1200
+ return { item: { type: 'text', node }, frag: node };
1201
+ }
1202
+ return { item: { type: 'empty' }, frag: null };
1203
+ }
1204
+
1205
+ /**
1206
+ * Initial render of a plain array: insert every slot's nodes before the marker.
1207
+ * @param {Comment} marker
1208
+ * @param {{ kind: 'array', items: ArrayItem[] }} state
1209
+ * @param {unknown[]} value
1210
+ */
1211
+ function applyArrayFresh(marker, state, value) {
1212
+ const parent = marker.parentNode;
1213
+ if (!parent) return;
1214
+ const bulk = document.createDocumentFragment();
1215
+ /** @type {ArrayItem[]} */
1216
+ const items = [];
1217
+ for (const v of value) {
1218
+ const { item, frag } = buildArrayItem(v);
1219
+ if (frag) bulk.appendChild(frag);
1220
+ items.push(item);
1221
+ }
1222
+ parent.insertBefore(bulk, marker);
1223
+ state.items = items;
1224
+ }
1225
+
1226
+ /**
1227
+ * Positional (non-keyed) reconciliation. For each index, update the slot
1228
+ * in place when its shape is unchanged (preserving DOM node identity),
1229
+ * otherwise replace it; grow or shrink at the tail. There is no key
1230
+ * matching, so a reorder reduces to a series of in-place value updates;
1231
+ * reach for `repeat()` when element identity must follow a moved key.
1232
+ * @param {Extract<BoundPart, {kind:'child'}>} part
1233
+ * @param {unknown[]} value
1234
+ */
1235
+ function reconcileArray(part, value) {
1236
+ const marker = part.marker;
1237
+ const parent = marker.parentNode;
1238
+ if (!parent) return;
1239
+ const state = /** @type {{ kind: 'array', items: ArrayItem[] }} */ (part.child);
1240
+ const old = state.items;
1241
+ /** @type {ArrayItem[]} */
1242
+ const next = [];
1243
+
1244
+ for (let i = 0; i < value.length; i++) {
1245
+ const v = value[i];
1246
+ const o = old[i];
1247
+ if (isTemplate(v)) {
1248
+ const tr = /** @type any */ (v);
1249
+ if (o && o.type === 'tpl' && o.inst.strings === tr.strings) {
1250
+ updateInstance(o.inst, tr.values);
1251
+ next.push(o);
1252
+ continue;
1253
+ }
1254
+ } else if (v != null && v !== false && v !== true) {
1255
+ if (o && o.type === 'text') {
1256
+ const str = String(v);
1257
+ if (o.node.data !== str) o.node.data = str;
1258
+ next.push(o);
1259
+ continue;
1260
+ }
1261
+ } else {
1262
+ // Empty slot: drop any prior nodes that occupied this position.
1263
+ if (o) removeArrayItem(o);
1264
+ next.push({ type: 'empty' });
1265
+ continue;
1266
+ }
1267
+ // Shape changed, or the array grew past the old length. Build fresh,
1268
+ // insert at this position (before the current / next still-attached
1269
+ // old node, else the marker), then drop the old slot it replaced.
1270
+ const { item, frag } = buildArrayItem(v);
1271
+ if (frag) parent.insertBefore(frag, nextArrayAnchor(old, i, marker));
1272
+ if (o) removeArrayItem(o);
1273
+ next.push(item);
1274
+ }
1275
+
1276
+ // Shrink: remove slots beyond the new length.
1277
+ for (let i = value.length; i < old.length; i++) removeArrayItem(old[i]);
1278
+ state.items = next;
1279
+ }
1280
+
1281
+ /**
1282
+ * The node a freshly-built slot at index `i` inserts before: the first
1283
+ * node of the current or next still-attached old slot, else the part
1284
+ * marker (a tail append).
1285
+ * @param {ArrayItem[]} old @param {number} i @param {Comment} marker
1286
+ * @returns {ChildNode}
1287
+ */
1288
+ function nextArrayAnchor(old, i, marker) {
1289
+ for (let j = i; j < old.length; j++) {
1290
+ const f = arrayItemFirstNode(old[j]);
1291
+ if (f && f.parentNode) return f;
1292
+ }
1293
+ return marker;
1294
+ }
1295
+
1296
+ /** @param {{ kind: 'array', items: ArrayItem[] }} state */
1297
+ function teardownArray(state) {
1298
+ for (const it of state.items) removeArrayItem(it);
1299
+ state.items = [];
1300
+ }
1301
+
1153
1302
  /**
1154
1303
  * Collect [start .. end] (inclusive) and insert immediately before `anchor`.
1155
1304
  * Browsers treat insertBefore of an already-connected node as a move and
@@ -1208,6 +1357,8 @@ function teardownChild(part) {
1208
1357
  const c = /** @type any */ (part.child);
1209
1358
  if (c.kind === 'repeat') {
1210
1359
  teardownRepeat(c);
1360
+ } else if (c.kind === 'array') {
1361
+ teardownArray(c);
1211
1362
  } else if (c.kind === 'async-stream') {
1212
1363
  teardownAsyncStream(c);
1213
1364
  } else if ('strings' in c) {
@@ -0,0 +1,14 @@
1
+ export interface SuspenseContext {
2
+ pending: { id: string; promise: Promise<unknown> }[];
3
+ nextId: number;
4
+ }
5
+
6
+ export function renderToString(
7
+ value: unknown,
8
+ opts?: { ssr?: boolean; suspenseCtx?: SuspenseContext },
9
+ ): Promise<string>;
10
+
11
+ export function renderToStream(
12
+ value: unknown,
13
+ opts?: { ssr?: boolean; suspenseCtx?: SuspenseContext },
14
+ ): ReadableStream;
@@ -0,0 +1,10 @@
1
+ import type { Route } from './routes.js';
2
+
3
+ export function enableClientRouter(): void;
4
+ export function disableClientRouter(): void;
5
+ export function navigate(url: Route, opts?: { replace?: boolean }): Promise<void>;
6
+ export function loadFrame(
7
+ frameEl: Element,
8
+ url: string,
9
+ ): Promise<{ ok: boolean; status: number | null; aborted: boolean }>;
10
+ export function revalidate(url?: string): void;