@barefootjs/client 0.17.1 → 0.18.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/dist/build.d.ts +16 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +3 -1
- package/dist/reactive.d.ts.map +1 -1
- package/dist/reactive.js +59 -26
- package/dist/runtime/component.d.ts +13 -3
- package/dist/runtime/component.d.ts.map +1 -1
- package/dist/runtime/index.js +128 -46
- package/dist/runtime/map-array.d.ts.map +1 -1
- package/dist/runtime/standalone.js +187 -72
- package/package.json +2 -2
- package/src/build.ts +16 -0
- package/src/reactive.ts +167 -32
- package/src/runtime/component.ts +14 -3
- package/src/runtime/map-array.ts +172 -29
package/src/reactive.ts
CHANGED
|
@@ -88,8 +88,16 @@ export interface ProfilerEventSink {
|
|
|
88
88
|
turnEnd(): void
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
/**
|
|
92
|
-
|
|
91
|
+
/**
|
|
92
|
+
* A signal's subscriber set, tagged with the signal's id for edge-removal
|
|
93
|
+
* events. `__bfSnapshot` is a dispatch-order cache for `set()` (perf): the
|
|
94
|
+
* array `[...subscribers]` would otherwise allocate on every write, but a
|
|
95
|
+
* stable subscriber graph (the common case — see `runEffect`'s dependency
|
|
96
|
+
* sweep) never mutates this Set between writes, so the same array can be
|
|
97
|
+
* reused. `undefined`/`null` means "no valid cache, rebuild on next write";
|
|
98
|
+
* every call site that adds or removes a member sets it back to `null`.
|
|
99
|
+
*/
|
|
100
|
+
type SubscriberSet = Set<EffectContext> & { __bfSignalId?: string; __bfSnapshot?: EffectContext[] | null }
|
|
93
101
|
|
|
94
102
|
let profilerSink: ProfilerEventSink | null = null
|
|
95
103
|
let signalSeq = 0
|
|
@@ -142,9 +150,34 @@ export function __bfReportOutput(changed: boolean): void {
|
|
|
142
150
|
type EffectContext = {
|
|
143
151
|
fn: EffectFn
|
|
144
152
|
cleanup: CleanupFn | null
|
|
145
|
-
|
|
153
|
+
// Signals read by the most recently completed run, mapped to the `gen`
|
|
154
|
+
// stamp they were (re)confirmed at. A re-run that reads the SAME signal
|
|
155
|
+
// again just refreshes its stamp in `get()` (no Set.delete/add on the
|
|
156
|
+
// signal's subscriber Set); one that stops reading a signal leaves that
|
|
157
|
+
// entry stamped with a stale `gen`, which the end-of-run sweep in
|
|
158
|
+
// `runEffect` detects and removes (perf: this is what makes a stable
|
|
159
|
+
// dependency graph — the overwhelmingly common case — free of the
|
|
160
|
+
// unsubscribe/resubscribe churn a naive clear-and-rebuild pays on every
|
|
161
|
+
// single run, while keeping dynamic dependency tracking exact).
|
|
162
|
+
dependencies: Map<SubscriberSet, number>
|
|
163
|
+
// Monotonic per-effect run counter, bumped once per invocation (including
|
|
164
|
+
// reentrant/circular ones — each nested call gets its own higher value).
|
|
165
|
+
// Stamped onto `dependencies` entries in `get()`; compared against by the
|
|
166
|
+
// OUTERMOST invocation's end-of-run sweep to find entries the most recent
|
|
167
|
+
// run didn't touch. Never compared across effects.
|
|
168
|
+
gen: number
|
|
146
169
|
owner: EffectContext | null // Parent scope for hierarchical disposal
|
|
147
|
-
|
|
170
|
+
// Owned child effects/roots. A `Set` (not an array) so a single child's
|
|
171
|
+
// detach in `disposeEffect` is O(1) — `mapArray` disposes large sibling
|
|
172
|
+
// groups one scope at a time (list clear/replace), and an array would need
|
|
173
|
+
// an `indexOf` scan per removal, making that O(n²) in the sibling count
|
|
174
|
+
// (bulk-disposal perf). Insertion order is preserved (Set iterates in
|
|
175
|
+
// insertion order), so cascade disposal in `disposeSubtree` still walks
|
|
176
|
+
// children in the same order they were created. Lazily allocated — most
|
|
177
|
+
// effects never gain a child (leaf effects/memos), so `null` until the
|
|
178
|
+
// first `owner.children.add(...)` avoids a per-effect Set allocation on
|
|
179
|
+
// the common path (perf).
|
|
180
|
+
children: Set<EffectContext> | null
|
|
148
181
|
disposed: boolean
|
|
149
182
|
runCount: number // Per-effect re-entry counter for circular dependency detection
|
|
150
183
|
id: string // Dev instrumentation id ('' when profiling is off)
|
|
@@ -161,6 +194,16 @@ let Owner: EffectContext | null = null
|
|
|
161
194
|
let Listener: EffectContext | null = null
|
|
162
195
|
const MAX_EFFECT_RUNS = 100
|
|
163
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Register `child` under `owner`, lazily allocating `owner.children` on the
|
|
199
|
+
* first registration (perf: most effects never gain a child, so this keeps
|
|
200
|
+
* the common leaf-effect path allocation-free).
|
|
201
|
+
*/
|
|
202
|
+
function registerChild(owner: EffectContext, child: EffectContext): void {
|
|
203
|
+
if (!owner.children) owner.children = new Set()
|
|
204
|
+
owner.children.add(child)
|
|
205
|
+
}
|
|
206
|
+
|
|
164
207
|
let BatchDepth = 0
|
|
165
208
|
const PendingEffects = new Set<EffectContext>()
|
|
166
209
|
|
|
@@ -186,9 +229,33 @@ export function createSignal<T>(initialValue: T, __bfId?: string): Signal<T> {
|
|
|
186
229
|
|
|
187
230
|
const get = () => {
|
|
188
231
|
if (Listener) {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
232
|
+
if (profilerSink) {
|
|
233
|
+
// Exact historical event contract: unconditionally (re)add and fire
|
|
234
|
+
// `subscribeAdd` for every read call, even a signal read twice
|
|
235
|
+
// within the same run body — profiler consumers must see the same
|
|
236
|
+
// stream regardless of the perf path below (dev-only instrumentation,
|
|
237
|
+
// its cost doesn't matter). See profiler-instrumentation.test.ts.
|
|
238
|
+
// Always invalidates `__bfSnapshot`, even on a redundant re-add of an
|
|
239
|
+
// already-present member — cheap here (profiling is already off the
|
|
240
|
+
// fast path) and correctness-critical: a real new member (e.g. this
|
|
241
|
+
// signal previously had zero subscribers, cached an empty snapshot,
|
|
242
|
+
// and gains its first one here) must not leave a stale empty cache
|
|
243
|
+
// behind for `set()` to reuse.
|
|
244
|
+
subscribers.add(Listener)
|
|
245
|
+
subscribers.__bfSnapshot = null
|
|
246
|
+
Listener.dependencies.set(subscribers, Listener.gen)
|
|
247
|
+
profilerSink.subscribeAdd(id, Listener.id)
|
|
248
|
+
} else if (!Listener.dependencies.has(subscribers)) {
|
|
249
|
+
// New dependency for this effect (first time it's read this signal,
|
|
250
|
+
// or it stopped and started again) — the only case that needs to
|
|
251
|
+
// touch the signal's subscriber Set. An already-tracked dependency
|
|
252
|
+
// (the common case) just gets its stamp refreshed below.
|
|
253
|
+
subscribers.add(Listener)
|
|
254
|
+
subscribers.__bfSnapshot = null
|
|
255
|
+
Listener.dependencies.set(subscribers, Listener.gen)
|
|
256
|
+
} else {
|
|
257
|
+
Listener.dependencies.set(subscribers, Listener.gen)
|
|
258
|
+
}
|
|
192
259
|
}
|
|
193
260
|
return value
|
|
194
261
|
}
|
|
@@ -211,9 +278,25 @@ export function createSignal<T>(initialValue: T, __bfId?: string): Signal<T> {
|
|
|
211
278
|
PendingEffects.add(effect)
|
|
212
279
|
}
|
|
213
280
|
} else {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
281
|
+
// Snapshot the subscriber list before dispatch so an effect that
|
|
282
|
+
// subscribes/unsubscribes DURING this write's dispatch (a nested
|
|
283
|
+
// re-run, or a disposal triggered from an effect body) can't change
|
|
284
|
+
// what THIS write runs — same fixed-at-dispatch-time semantics as the
|
|
285
|
+
// previous per-write `[...subscribers]` copy (pinned in
|
|
286
|
+
// reactive.test.ts's "dispatch snapshot" cases).
|
|
287
|
+
//
|
|
288
|
+
// The array is cached on the Set itself and only rebuilt when
|
|
289
|
+
// membership actually changes (`get()`'s new-dependency branch, the
|
|
290
|
+
// end-of-run sweep, and disposal all null it out) — a stable
|
|
291
|
+
// subscriber graph (effects that read the same signals every run,
|
|
292
|
+
// the common case) reuses the same array across every subsequent
|
|
293
|
+
// write instead of paying a fresh allocation + copy each time.
|
|
294
|
+
let snapshot = subscribers.__bfSnapshot
|
|
295
|
+
if (!snapshot) {
|
|
296
|
+
snapshot = subscribers.__bfSnapshot = [...subscribers]
|
|
297
|
+
}
|
|
298
|
+
for (let i = 0; i < snapshot.length; i++) {
|
|
299
|
+
runEffect(snapshot[i]!)
|
|
217
300
|
}
|
|
218
301
|
}
|
|
219
302
|
}
|
|
@@ -241,9 +324,10 @@ export function createEffect(fn: EffectFn, __bfId?: string, __bfKind: Subscriber
|
|
|
241
324
|
const effect: EffectContext = {
|
|
242
325
|
fn,
|
|
243
326
|
cleanup: null,
|
|
244
|
-
dependencies: new
|
|
327
|
+
dependencies: new Map(),
|
|
328
|
+
gen: 0,
|
|
245
329
|
owner: Owner,
|
|
246
|
-
children:
|
|
330
|
+
children: null,
|
|
247
331
|
disposed: false,
|
|
248
332
|
runCount: 0,
|
|
249
333
|
id: __bfId ?? (profilerSink ? `e${++subscriberSeq}` : ''),
|
|
@@ -255,7 +339,7 @@ export function createEffect(fn: EffectFn, __bfId?: string, __bfKind: Subscriber
|
|
|
255
339
|
if (profilerSink) profilerSink.effectCreate(effect.id, effect.kind)
|
|
256
340
|
|
|
257
341
|
// Register with parent owner for hierarchical disposal
|
|
258
|
-
if (Owner) Owner
|
|
342
|
+
if (Owner) registerChild(Owner, effect)
|
|
259
343
|
|
|
260
344
|
runEffect(effect)
|
|
261
345
|
}
|
|
@@ -268,6 +352,15 @@ function runEffect(effect: EffectContext): void {
|
|
|
268
352
|
effect.runCount = 0
|
|
269
353
|
throw new Error(`Circular dependency detected: effect re-entered itself ${MAX_EFFECT_RUNS} times.`)
|
|
270
354
|
}
|
|
355
|
+
// Captured right after the reentrancy counter above so a nested re-entrant
|
|
356
|
+
// run of this SAME effect (the circular-dependency guard above bounds how
|
|
357
|
+
// deep that can go) doesn't run the end-of-run dependency sweep against
|
|
358
|
+
// its own, shallower run: only the outermost call sweeps, using whatever
|
|
359
|
+
// `effect.gen` the deepest/most-recently-completed nested run left behind
|
|
360
|
+
// — that run's reads are the authoritative "current dependencies" for the
|
|
361
|
+
// whole reentrant chain, exactly like the old clear-at-every-entry code's
|
|
362
|
+
// last write won.
|
|
363
|
+
const isOutermostRun = effect.runCount === 1
|
|
271
364
|
|
|
272
365
|
// `effectEnter` is emitted *before* cleanup so the whole run — cleanup
|
|
273
366
|
// included — is bracketed by enter/exit. A `set()` performed inside a cleanup
|
|
@@ -282,16 +375,30 @@ function runEffect(effect: EffectContext): void {
|
|
|
282
375
|
effect.cleanup = null
|
|
283
376
|
}
|
|
284
377
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
378
|
+
if (profilerSink) {
|
|
379
|
+
// Dev-instrumented path only: reproduce the exact pre-optimization event
|
|
380
|
+
// contract by dropping every dependency up front, so `get()` re-adds (and
|
|
381
|
+
// re-fires `subscribeAdd` for) everything the run reads — including a
|
|
382
|
+
// signal read twice in one body. Profiling is dev-only and its cost
|
|
383
|
+
// doesn't matter; what matters is that turning it on never changes the
|
|
384
|
+
// event stream a consumer sees. The perf path below (no profiler) uses
|
|
385
|
+
// the cheaper end-of-run sweep instead.
|
|
386
|
+
for (const dep of effect.dependencies.keys()) {
|
|
387
|
+
dep.delete(effect)
|
|
388
|
+
// Membership changed — a dep this run doesn't re-read would otherwise
|
|
389
|
+
// leave a stale cached dispatch snapshot that still contains this
|
|
390
|
+
// effect (re-reads re-add via `get()`, which re-invalidates anyway).
|
|
391
|
+
dep.__bfSnapshot = null
|
|
392
|
+
profilerSink.subscribeRemove(dep.__bfSignalId ?? '', effect.id)
|
|
393
|
+
}
|
|
394
|
+
effect.dependencies.clear()
|
|
288
395
|
}
|
|
289
|
-
effect.dependencies.clear()
|
|
290
396
|
|
|
291
397
|
const prevOwner = Owner
|
|
292
398
|
const prevListener = Listener
|
|
293
399
|
Owner = effect
|
|
294
400
|
Listener = effect
|
|
401
|
+
effect.gen++
|
|
295
402
|
|
|
296
403
|
// Fresh output fingerprint for this run (§4.2.2); `__bfReportOutput` fills it.
|
|
297
404
|
effect.outputReported = false
|
|
@@ -310,6 +417,25 @@ function runEffect(effect: EffectContext): void {
|
|
|
310
417
|
Owner = prevOwner
|
|
311
418
|
Listener = prevListener
|
|
312
419
|
effect.runCount--
|
|
420
|
+
|
|
421
|
+
if (!profilerSink && isOutermostRun) {
|
|
422
|
+
// Sweep: a dependency whose stamp isn't the current `effect.gen`
|
|
423
|
+
// wasn't (re)read by the most recent run of this effect's body, so it
|
|
424
|
+
// must be dropped — this is what keeps dynamic dependency tracking
|
|
425
|
+
// exact (an effect that stops reading a signal stops depending on it).
|
|
426
|
+
// A dependency that IS still read every run (the overwhelmingly common
|
|
427
|
+
// case: most effects' read set never changes) never reaches this
|
|
428
|
+
// branch at all — `get()` above just refreshed its stamp, no
|
|
429
|
+
// Set.delete/add round trip on the signal's subscriber Set.
|
|
430
|
+
for (const [dep, gen] of effect.dependencies) {
|
|
431
|
+
if (gen !== effect.gen) {
|
|
432
|
+
effect.dependencies.delete(dep)
|
|
433
|
+
dep.delete(effect)
|
|
434
|
+
dep.__bfSnapshot = null
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
313
439
|
if (profilerSink) {
|
|
314
440
|
profilerSink.effectExit(effect.id, performance.now() - start)
|
|
315
441
|
if (effect.outputReported) profilerSink.effectOutput?.(effect.id, effect.outputChanged)
|
|
@@ -327,18 +453,21 @@ function disposeSubtree(effect: EffectContext): void {
|
|
|
327
453
|
if (effect.disposed) return
|
|
328
454
|
effect.disposed = true
|
|
329
455
|
|
|
330
|
-
|
|
331
|
-
|
|
456
|
+
if (effect.children) {
|
|
457
|
+
for (const child of effect.children) {
|
|
458
|
+
disposeSubtree(child)
|
|
459
|
+
}
|
|
460
|
+
effect.children.clear()
|
|
332
461
|
}
|
|
333
|
-
effect.children.length = 0
|
|
334
462
|
|
|
335
463
|
if (effect.cleanup) {
|
|
336
464
|
effect.cleanup()
|
|
337
465
|
effect.cleanup = null
|
|
338
466
|
}
|
|
339
467
|
|
|
340
|
-
for (const dep of effect.dependencies) {
|
|
468
|
+
for (const dep of effect.dependencies.keys()) {
|
|
341
469
|
dep.delete(effect)
|
|
470
|
+
dep.__bfSnapshot = null
|
|
342
471
|
if (profilerSink) profilerSink.subscribeRemove(dep.__bfSignalId ?? '', effect.id)
|
|
343
472
|
}
|
|
344
473
|
effect.dependencies.clear()
|
|
@@ -350,19 +479,23 @@ function disposeSubtree(effect: EffectContext): void {
|
|
|
350
479
|
|
|
351
480
|
/**
|
|
352
481
|
* Public dispose entry point: detach `effect` from its parent's `children`
|
|
353
|
-
*
|
|
482
|
+
* set, then dispose the subtree rooted at `effect`. The detach step lives
|
|
354
483
|
* only here so the recursion (which doesn't need it — the parent clears
|
|
355
|
-
*
|
|
356
|
-
* it is iterating. The
|
|
484
|
+
* the whole set itself via `children.clear()`) cannot mutate the set
|
|
485
|
+
* it is iterating. The mutate-during-iterate shape was the root cause of
|
|
357
486
|
* #1366; separating the two responsibilities makes it structurally
|
|
358
487
|
* unreachable.
|
|
488
|
+
*
|
|
489
|
+
* `Set.delete` is O(1), so detaching one child from a parent that owns many
|
|
490
|
+
* siblings (e.g. `mapArray` disposing a cleared/replaced keyed list one
|
|
491
|
+
* `createRoot` scope at a time) no longer costs an `indexOf` scan over the
|
|
492
|
+
* remaining siblings — that scan was O(n) per disposal, O(n²) for the batch.
|
|
359
493
|
*/
|
|
360
494
|
function disposeEffect(effect: EffectContext): void {
|
|
361
495
|
if (effect.disposed) return
|
|
362
496
|
|
|
363
497
|
if (effect.owner) {
|
|
364
|
-
|
|
365
|
-
if (idx >= 0) effect.owner.children.splice(idx, 1)
|
|
498
|
+
effect.owner.children?.delete(effect)
|
|
366
499
|
}
|
|
367
500
|
|
|
368
501
|
disposeSubtree(effect)
|
|
@@ -382,9 +515,10 @@ export function createRoot<T>(fn: (dispose: () => void) => T): T {
|
|
|
382
515
|
const root: EffectContext = {
|
|
383
516
|
fn: () => {},
|
|
384
517
|
cleanup: null,
|
|
385
|
-
dependencies: new
|
|
518
|
+
dependencies: new Map(),
|
|
519
|
+
gen: 0,
|
|
386
520
|
owner: Owner,
|
|
387
|
-
children:
|
|
521
|
+
children: null,
|
|
388
522
|
disposed: false,
|
|
389
523
|
runCount: 0,
|
|
390
524
|
id: profilerSink ? `r${++subscriberSeq}` : '',
|
|
@@ -395,7 +529,7 @@ export function createRoot<T>(fn: (dispose: () => void) => T): T {
|
|
|
395
529
|
|
|
396
530
|
if (profilerSink) profilerSink.effectCreate(root.id, 'root')
|
|
397
531
|
|
|
398
|
-
if (Owner) Owner
|
|
532
|
+
if (Owner) registerChild(Owner, root)
|
|
399
533
|
|
|
400
534
|
const prevOwner = Owner
|
|
401
535
|
const prevListener = Listener
|
|
@@ -425,9 +559,10 @@ export function createDisposableEffect(fn: EffectFn, __bfId?: string): () => voi
|
|
|
425
559
|
return fn()
|
|
426
560
|
},
|
|
427
561
|
cleanup: null,
|
|
428
|
-
dependencies: new
|
|
562
|
+
dependencies: new Map(),
|
|
563
|
+
gen: 0,
|
|
429
564
|
owner: Owner,
|
|
430
|
-
children:
|
|
565
|
+
children: null,
|
|
431
566
|
disposed: false,
|
|
432
567
|
runCount: 0,
|
|
433
568
|
id: __bfId ?? (profilerSink ? `e${++subscriberSeq}` : ''),
|
|
@@ -438,7 +573,7 @@ export function createDisposableEffect(fn: EffectFn, __bfId?: string): () => voi
|
|
|
438
573
|
|
|
439
574
|
if (profilerSink) profilerSink.effectCreate(effect.id, effect.kind)
|
|
440
575
|
|
|
441
|
-
if (Owner) Owner
|
|
576
|
+
if (Owner) registerChild(Owner, effect)
|
|
442
577
|
|
|
443
578
|
runEffect(effect)
|
|
444
579
|
|
package/src/runtime/component.ts
CHANGED
|
@@ -463,12 +463,23 @@ export function escapeAttr(value: unknown): string {
|
|
|
463
463
|
* slots). The HTML spec only requires `& < >` in text, but the SSR
|
|
464
464
|
* adapters (Hono) escape text with the same set as attribute values
|
|
465
465
|
* (`& " ' < >`), and the fixture-hydrate / CSR-conformance layer requires
|
|
466
|
-
* byte-parity with the server-rendered output — so
|
|
467
|
-
* `escapeAttr`. Kept as a distinct export so generated code reads
|
|
466
|
+
* byte-parity with the server-rendered output — so the escaping delegates
|
|
467
|
+
* to `escapeAttr`. Kept as a distinct export so generated code reads
|
|
468
468
|
* `escapeText(...)` at text sites (self-documenting) and so the two
|
|
469
|
-
* contexts can diverge
|
|
469
|
+
* contexts can diverge (as they now do for nullish) without touching call
|
|
470
|
+
* sites.
|
|
471
|
+
*
|
|
472
|
+
* A nullish value renders as empty text — the JSX/Solid semantics the Hono
|
|
473
|
+
* SSR reference follows (`{undefined}` / `{null}` produce no text), and
|
|
474
|
+
* what the reactive text-update path already does (`dynamic-text.ts` and
|
|
475
|
+
* `client-marker.ts` both `String(value ?? '')`). Only this initial-render
|
|
476
|
+
* escape site used to stringify `undefined` / `null` into literal
|
|
477
|
+
* "undefined" / "null" text, so a bare `{props.x}` on an absent prop
|
|
478
|
+
* diverged from SSR at first paint (#2137). Non-nullish values (including
|
|
479
|
+
* `0` and `false`) keep their `String()` form, matching the reactive path.
|
|
470
480
|
*/
|
|
471
481
|
export function escapeText(value: unknown): string {
|
|
482
|
+
if (value == null) return ''
|
|
472
483
|
return escapeAttr(value)
|
|
473
484
|
}
|
|
474
485
|
|
package/src/runtime/map-array.ts
CHANGED
|
@@ -72,7 +72,11 @@ function findLoopMarkers(
|
|
|
72
72
|
if (markerId) {
|
|
73
73
|
const startVal = loopStartMarker(markerId)
|
|
74
74
|
const endVal = loopEndMarker(markerId)
|
|
75
|
-
|
|
75
|
+
// Walk via firstChild/nextSibling rather than Array.from(childNodes) —
|
|
76
|
+
// this runs on every un-cached lookup (see `resolveMarkers` in
|
|
77
|
+
// `mapArray`), so avoiding the intermediate array allocation matters
|
|
78
|
+
// for large sibling counts.
|
|
79
|
+
for (let node = container.firstChild; node; node = node.nextSibling) {
|
|
76
80
|
if (node.nodeType !== Node.COMMENT_NODE) continue
|
|
77
81
|
const value = (node as Comment).nodeValue
|
|
78
82
|
if (value === startVal) start = node as Comment
|
|
@@ -81,7 +85,7 @@ function findLoopMarkers(
|
|
|
81
85
|
} else {
|
|
82
86
|
const startPrefix = `${BF_LOOP_START}:`
|
|
83
87
|
const endPrefix = `${BF_LOOP_END}:`
|
|
84
|
-
for (
|
|
88
|
+
for (let node = container.firstChild; node; node = node.nextSibling) {
|
|
85
89
|
if (node.nodeType !== Node.COMMENT_NODE) continue
|
|
86
90
|
const value = (node as Comment).nodeValue ?? ''
|
|
87
91
|
if (!start && (value === BF_LOOP_START || value.startsWith(startPrefix))) {
|
|
@@ -140,14 +144,61 @@ function findItemRanges(start: Comment, end: Comment): Array<{
|
|
|
140
144
|
}
|
|
141
145
|
|
|
142
146
|
/**
|
|
143
|
-
* Insert a scope's nodes into
|
|
147
|
+
* Insert a scope's nodes into `target` in their canonical order
|
|
144
148
|
* (startMarker → primaryEl → extras). Idempotent — `insertBefore` on a
|
|
145
149
|
* node already at the target position is a no-op.
|
|
150
|
+
*
|
|
151
|
+
* `target` is typed as `Node` (not `HTMLElement`) so callers can pass a
|
|
152
|
+
* `DocumentFragment` to batch several scopes into one subsequent
|
|
153
|
+
* `container.insertBefore(fragment, anchor)` call — see the minimal-move
|
|
154
|
+
* reorder in `mapArray`.
|
|
146
155
|
*/
|
|
147
|
-
function insertScope<T>(scope: ItemScope<T>,
|
|
148
|
-
if (scope.startMarker)
|
|
149
|
-
|
|
150
|
-
for (const ex of scope.extras)
|
|
156
|
+
function insertScope<T>(scope: ItemScope<T>, target: Node, anchor: Node | null): void {
|
|
157
|
+
if (scope.startMarker) target.insertBefore(scope.startMarker, anchor)
|
|
158
|
+
target.insertBefore(scope.primaryEl, anchor)
|
|
159
|
+
for (const ex of scope.extras) target.insertBefore(ex, anchor)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Longest increasing subsequence, returned as ascending indices into `arr`.
|
|
164
|
+
* O(n log n) patience sorting with predecessor backtracking.
|
|
165
|
+
*
|
|
166
|
+
* Used by `mapArray`'s reorder step: `arr` holds, for each already-attached
|
|
167
|
+
* scope encountered while walking the live DOM in its current order, the
|
|
168
|
+
* scope's index in the *desired* order. The LIS of that array is the
|
|
169
|
+
* largest set of scopes whose relative order already matches the desired
|
|
170
|
+
* order — those scopes can stay exactly where they are; every other scope
|
|
171
|
+
* (plus any brand-new one) needs to move. This is the same strategy
|
|
172
|
+
* keyed-diff reconcilers in the udomdiff/Solid family use to turn an
|
|
173
|
+
* arbitrary reorder into a minimal set of DOM moves.
|
|
174
|
+
*/
|
|
175
|
+
function longestIncreasingSubsequenceIndices(arr: number[]): number[] {
|
|
176
|
+
const n = arr.length
|
|
177
|
+
if (n === 0) return []
|
|
178
|
+
// tails[k] = index into `arr` of the smallest possible tail value for an
|
|
179
|
+
// increasing subsequence of length k + 1.
|
|
180
|
+
const tails: number[] = []
|
|
181
|
+
const predecessors: number[] = new Array(n).fill(-1)
|
|
182
|
+
for (let i = 0; i < n; i++) {
|
|
183
|
+
const value = arr[i]
|
|
184
|
+
let lo = 0
|
|
185
|
+
let hi = tails.length
|
|
186
|
+
while (lo < hi) {
|
|
187
|
+
const mid = (lo + hi) >>> 1
|
|
188
|
+
if (arr[tails[mid]] < value) lo = mid + 1
|
|
189
|
+
else hi = mid
|
|
190
|
+
}
|
|
191
|
+
if (lo > 0) predecessors[i] = tails[lo - 1]
|
|
192
|
+
if (lo === tails.length) tails.push(i)
|
|
193
|
+
else tails[lo] = i
|
|
194
|
+
}
|
|
195
|
+
const result: number[] = new Array(tails.length)
|
|
196
|
+
let k = tails[tails.length - 1]
|
|
197
|
+
for (let i = tails.length - 1; i >= 0; i--) {
|
|
198
|
+
result[i] = k
|
|
199
|
+
k = predecessors[k]
|
|
200
|
+
}
|
|
201
|
+
return result
|
|
151
202
|
}
|
|
152
203
|
|
|
153
204
|
/** Detach all of a scope's nodes from the DOM. */
|
|
@@ -229,11 +280,29 @@ export function mapArray<T>(
|
|
|
229
280
|
const scopes = new Map<string, ItemScope<T>>()
|
|
230
281
|
let hydrated = false
|
|
231
282
|
|
|
283
|
+
// Loop boundary markers are structural — this module never removes or
|
|
284
|
+
// re-inserts them — so they can be found once and reused across every
|
|
285
|
+
// effect run instead of rescanning `container.childNodes` on every
|
|
286
|
+
// reconcile. `isConnected` guards against the (unusual) case of the
|
|
287
|
+
// container itself being torn down and rebuilt out from under this
|
|
288
|
+
// closure; in that case we fall back to a fresh lookup.
|
|
289
|
+
let cachedStart: Comment | null = null
|
|
290
|
+
let cachedEnd: Comment | null = null
|
|
291
|
+
const resolveMarkers = (): { start: Comment | null; end: Comment | null } => {
|
|
292
|
+
if (cachedStart && cachedEnd && cachedStart.isConnected && cachedEnd.isConnected) {
|
|
293
|
+
return { start: cachedStart, end: cachedEnd }
|
|
294
|
+
}
|
|
295
|
+
const found = findLoopMarkers(container, markerId)
|
|
296
|
+
cachedStart = found.start
|
|
297
|
+
cachedEnd = found.end
|
|
298
|
+
return found
|
|
299
|
+
}
|
|
300
|
+
|
|
232
301
|
createEffect(() => {
|
|
233
302
|
const items = accessor()
|
|
234
303
|
if (!items) return
|
|
235
304
|
|
|
236
|
-
const { start: startMarker, end: endMarker } =
|
|
305
|
+
const { start: startMarker, end: endMarker } = resolveMarkers()
|
|
237
306
|
const anchor = endMarker ?? null
|
|
238
307
|
|
|
239
308
|
// --- First run: hydrate SSR-rendered children ---
|
|
@@ -315,6 +384,43 @@ export function mapArray<T>(
|
|
|
315
384
|
}
|
|
316
385
|
}
|
|
317
386
|
|
|
387
|
+
// --- Fast path: clearing the whole list ---
|
|
388
|
+
// When every existing scope is being removed, dispose them all and then
|
|
389
|
+
// remove their DOM in one bulk operation instead of one `.remove()` per
|
|
390
|
+
// scope. Loop markers (and their bracketing range) are always preserved.
|
|
391
|
+
if (items.length === 0) {
|
|
392
|
+
if (scopes.size > 0) {
|
|
393
|
+
for (const scope of scopes.values()) scope.dispose()
|
|
394
|
+
if (startMarker && endMarker) {
|
|
395
|
+
// Scoped (or per-item-marker) range: the space between the
|
|
396
|
+
// markers is owned entirely by this list, so a single Range
|
|
397
|
+
// delete clears it without touching the markers themselves.
|
|
398
|
+
const range = document.createRange()
|
|
399
|
+
range.setStartAfter(startMarker)
|
|
400
|
+
range.setEndBefore(endMarker)
|
|
401
|
+
range.deleteContents()
|
|
402
|
+
} else {
|
|
403
|
+
// Unscoped: the list owns the container's children directly.
|
|
404
|
+
// Verify no foreign siblings snuck in before nuking everything —
|
|
405
|
+
// counting is O(n), same order as the disposal loop above, so it
|
|
406
|
+
// doesn't add an asymptotically new cost.
|
|
407
|
+
let expectedNodeCount = 0
|
|
408
|
+
for (const scope of scopes.values()) {
|
|
409
|
+
expectedNodeCount += 1 + scope.extras.length + (scope.startMarker ? 1 : 0)
|
|
410
|
+
}
|
|
411
|
+
let actualNodeCount = 0
|
|
412
|
+
for (let node = container.firstChild; node; node = node.nextSibling) actualNodeCount++
|
|
413
|
+
if (actualNodeCount === expectedNodeCount) {
|
|
414
|
+
container.textContent = ''
|
|
415
|
+
} else {
|
|
416
|
+
for (const scope of scopes.values()) removeScope(scope)
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
scopes.clear()
|
|
420
|
+
}
|
|
421
|
+
return
|
|
422
|
+
}
|
|
423
|
+
|
|
318
424
|
// --- Key-based diff ---
|
|
319
425
|
const newKeys = new Set<string>()
|
|
320
426
|
// Distinct from `newKeys`: tracks which keys have ALREADY emitted a
|
|
@@ -365,30 +471,67 @@ export function mapArray<T>(
|
|
|
365
471
|
}
|
|
366
472
|
}
|
|
367
473
|
|
|
368
|
-
// Reconcile DOM order:
|
|
474
|
+
// --- Reconcile DOM order: minimal-move, LIS-based ---
|
|
475
|
+
//
|
|
476
|
+
// Rather than "any mismatch reinserts every scope", find the longest
|
|
477
|
+
// run of already-attached scopes that are already in the right
|
|
478
|
+
// relative order — the longest increasing subsequence of their desired
|
|
479
|
+
// positions, walking the live DOM once — and never touch those. Every
|
|
480
|
+
// other scope (scopes that need to move, plus brand-new scopes not yet
|
|
481
|
+
// in the DOM at all) is grouped into contiguous runs and inserted with
|
|
482
|
+
// ONE insertBefore per run (a DocumentFragment when a run has more than
|
|
483
|
+
// one scope). A swap of two rows becomes exactly two single-scope
|
|
484
|
+
// moves; a bulk append becomes one fragment insert that never touches
|
|
485
|
+
// the existing rows; an unchanged order performs zero DOM mutations.
|
|
486
|
+
//
|
|
369
487
|
// Moving elements via insertBefore causes detach/reattach which makes
|
|
370
|
-
// focused inputs lose focus (controlled input flicker)
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
|
|
374
|
-
let
|
|
375
|
-
|
|
376
|
-
// Skip non-element nodes (comments, text) when looking for the primary element.
|
|
377
|
-
while (checkNode && checkNode.nodeType !== Node.ELEMENT_NODE) checkNode = checkNode.nextSibling
|
|
378
|
-
if (checkNode !== scope.primaryEl) { inOrder = false; break }
|
|
379
|
-
// Advance past the rest of the scope's extras.
|
|
380
|
-
checkNode = checkNode.nextSibling
|
|
381
|
-
for (let i = 0; i < scope.extras.length; i++) {
|
|
382
|
-
while (checkNode && checkNode.nodeType !== Node.ELEMENT_NODE) checkNode = checkNode.nextSibling
|
|
383
|
-
if (checkNode !== scope.extras[i]) { inOrder = false; break }
|
|
384
|
-
checkNode = checkNode.nextSibling
|
|
385
|
-
}
|
|
386
|
-
if (!inOrder) break
|
|
488
|
+
// focused inputs lose focus (controlled input flicker) — scopes kept
|
|
489
|
+
// stationary by the LIS are provably never detached, so that guarantee
|
|
490
|
+
// still holds for them.
|
|
491
|
+
const primaryElToDesiredIndex = new Map<HTMLElement, number>()
|
|
492
|
+
for (let i = 0; i < desiredOrder.length; i++) {
|
|
493
|
+
primaryElToDesiredIndex.set(desiredOrder[i].primaryEl, i)
|
|
387
494
|
}
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
495
|
+
|
|
496
|
+
// Old DOM order of currently-attached scopes, expressed as desired-order
|
|
497
|
+
// indices. Brand-new scopes aren't attached yet, so they simply never
|
|
498
|
+
// appear here — which is exactly what marks them for insertion below.
|
|
499
|
+
// Single O(n) walk, no Array.from allocation.
|
|
500
|
+
const domOrderIndices: number[] = []
|
|
501
|
+
for (
|
|
502
|
+
let node: Node | null = startMarker ? startMarker.nextSibling : container.firstChild;
|
|
503
|
+
node && node !== anchor;
|
|
504
|
+
node = node.nextSibling
|
|
505
|
+
) {
|
|
506
|
+
if (node.nodeType !== Node.ELEMENT_NODE) continue
|
|
507
|
+
const idx = primaryElToDesiredIndex.get(node as HTMLElement)
|
|
508
|
+
if (idx !== undefined) domOrderIndices.push(idx)
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const stationary = new Array<boolean>(desiredOrder.length).fill(false)
|
|
512
|
+
for (const pos of longestIncreasingSubsequenceIndices(domOrderIndices)) {
|
|
513
|
+
stationary[domOrderIndices[pos]] = true
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
let i = 0
|
|
517
|
+
while (i < desiredOrder.length) {
|
|
518
|
+
if (stationary[i]) { i++; continue }
|
|
519
|
+
let j = i
|
|
520
|
+
while (j < desiredOrder.length && !stationary[j]) j++
|
|
521
|
+
// Insert this run immediately before the next stationary scope (which
|
|
522
|
+
// is already exactly where it needs to be), or before the loop's
|
|
523
|
+
// trailing anchor when the run reaches the end of the list.
|
|
524
|
+
const before = j < desiredOrder.length
|
|
525
|
+
? (desiredOrder[j].startMarker ?? desiredOrder[j].primaryEl)
|
|
526
|
+
: anchor
|
|
527
|
+
if (j - i === 1) {
|
|
528
|
+
insertScope(desiredOrder[i], container, before)
|
|
529
|
+
} else {
|
|
530
|
+
const runFragment = document.createDocumentFragment()
|
|
531
|
+
for (let k = i; k < j; k++) insertScope(desiredOrder[k], runFragment, null)
|
|
532
|
+
container.insertBefore(runFragment, before)
|
|
391
533
|
}
|
|
534
|
+
i = j
|
|
392
535
|
}
|
|
393
536
|
}, bfId)
|
|
394
537
|
}
|