@abide/abide 0.45.0 → 0.46.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/CHANGELOG.md +16 -0
- package/package.json +1 -1
- package/src/lib/server/rpc/defineRpc.ts +2 -1
- package/src/lib/shared/DEFER_MIN_ARRAY_LENGTH.ts +14 -0
- package/src/lib/ui/compile/generateBuild.ts +5 -1
- package/src/lib/ui/compile/parseTemplate.ts +14 -1
- package/src/lib/ui/compile/types/TemplateNode.ts +5 -0
- package/src/lib/ui/deferResume.ts +7 -0
- package/src/lib/ui/dom/awaitBlock.ts +27 -2
- package/src/lib/ui/dom/firstElementBetween.ts +14 -0
- package/src/lib/ui/dom/mountChild.ts +5 -2
- package/src/lib/ui/dom/mountRange.ts +75 -0
- package/src/lib/ui/installHotBridge.ts +1 -1
- package/src/lib/ui/runtime/scheduleWake.ts +28 -0
- package/src/lib/ui/runtime/whenIdle.ts +21 -0
- package/src/lib/ui/runtime/whenVisible.ts +105 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# abide
|
|
2
2
|
|
|
3
|
+
## 0.46.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 162296f: wake a below-the-fold deferred {#await cache()} on visible, not idle ([`193ff2f`](https://github.com/briancray/abide/commit/193ff2f7ad4a40f3dde39537260d76e7317a6013))
|
|
8
|
+
- 162296f: client:idle / client:visible island directive on components ([`a50f0bc`](https://github.com/briancray/abide/commit/a50f0bce451f00f292eea90419feaa6ff1a708bd))
|
|
9
|
+
- 162296f: wake deferred {#await cache()} on idle + size-gate the defer decision ([`a5a1a7f`](https://github.com/briancray/abide/commit/a5a1a7fea3dfe35cad08ea17fad7016fb8cca6cb))
|
|
10
|
+
- 162296f: component-island hydration boundary (mountRange) + shared wake helpers ([`e0ed9ba`](https://github.com/briancray/abide/commit/e0ed9ba58d41ad7d1605717b1f4ec9207d2454a5))
|
|
11
|
+
|
|
12
|
+
### Patch Changes
|
|
13
|
+
|
|
14
|
+
- 162296f: rename shadowed `constructor` local in whenVisible ([`2f25fdf`](https://github.com/briancray/abide/commit/2f25fdfcb21e05807dd26c876acd8cd9f49d3004))
|
|
15
|
+
- 162296f: shared observer + frame-budgeted wake flush for islands at scale ([`3c2db3b`](https://github.com/briancray/abide/commit/3c2db3bce43ad8cc30b6cab279246a033db4bd61))
|
|
16
|
+
- 162296f: apply formatter to drifted files ([`5960d3c`](https://github.com/briancray/abide/commit/5960d3c5db44c925cf3c1fc9566156a8886db195))
|
|
17
|
+
- 162296f: restore multi-line union wrapping in generateBuild ([`92ce908`](https://github.com/briancray/abide/commit/92ce908c3369759d8b55c695ed2deed410be1ef5))
|
|
18
|
+
|
|
3
19
|
## 0.45.0
|
|
4
20
|
|
|
5
21
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -161,7 +161,8 @@ export function defineRpc<Args, Return>(
|
|
|
161
161
|
/* Abort the controller parseArgsForFetch stashed on store.req; a no-op when none was stashed (SSR cache reads). */
|
|
162
162
|
function abortRpcTimeout(): void {
|
|
163
163
|
const req = requestContext.getStore()?.req as
|
|
164
|
-
(Request & { [RPC_TIMEOUT_ABORT]?: AbortController })
|
|
164
|
+
| (Request & { [RPC_TIMEOUT_ABORT]?: AbortController })
|
|
165
|
+
| undefined
|
|
165
166
|
req?.[RPC_TIMEOUT_ABORT]?.abort(new DOMException('handler timeout', 'TimeoutError'))
|
|
166
167
|
}
|
|
167
168
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/*
|
|
2
|
+
The array-length above which a blocking `{#await cache()}` value is worth deferring (shipped
|
|
3
|
+
inert, materialized on idle) rather than inlined and hydrated eagerly at boot. Deferral trades
|
|
4
|
+
a live boot for a cheaper one; below this many rows the boot decode is cheap enough that the
|
|
5
|
+
trade doesn't pay, so the block stays eager — fully interactive from the first frame, no wake
|
|
6
|
+
needed. Only genuinely large grids cross it and take the inert-then-idle-wake path.
|
|
7
|
+
|
|
8
|
+
An O(1) `.length` heuristic (measured on the resolved value, before serialization): array-shaped
|
|
9
|
+
cache reads — lists, grids, tables — are the payloads big enough to matter, and the ones the
|
|
10
|
+
inert path was built for. Non-array values never defer. A deliberately generous default: with
|
|
11
|
+
idle-wake, a deferred block is interactive anyway (live before a human acts), so this is a
|
|
12
|
+
boot-performance knob, not a correctness one — set high so eager-and-simple is the common case.
|
|
13
|
+
*/
|
|
14
|
+
export const DEFER_MIN_ARRAY_LENGTH = 500
|
|
@@ -558,7 +558,11 @@ export function generateBuild(
|
|
|
558
558
|
parentVar: string,
|
|
559
559
|
before: string,
|
|
560
560
|
): string {
|
|
561
|
-
|
|
561
|
+
/* `client:idle`/`client:visible` → the island trigger, the 6th mountChild arg. Omitted
|
|
562
|
+
(the common case) leaves the call byte-identical to before — eager hydration. */
|
|
563
|
+
const trigger =
|
|
564
|
+
node.clientTrigger !== undefined ? `, ${JSON.stringify(node.clientTrigger)}` : ''
|
|
565
|
+
return `$$mountChild(${parentVar}, ${node.name}, ${propsArg(node)}, ${before}, ${JSON.stringify(node.name)}${trigger});\n`
|
|
562
566
|
}
|
|
563
567
|
|
|
564
568
|
/* An await block: pending → resolved(value) / error branches. Each branch is a
|
|
@@ -640,12 +640,25 @@ export function parseTemplate(source: string, baseOffset = 0): { nodes: Template
|
|
|
640
640
|
its children become slot content (rendered where the child puts <slot>). */
|
|
641
641
|
if (UPPERCASE_START.test(tag)) {
|
|
642
642
|
const slotted = selfClosing ? [] : readChildren(tag)
|
|
643
|
+
/* `client:idle` / `client:visible` are island directives (bare) — pull them out of
|
|
644
|
+
the attributes so they never round-trip as props; only the build back-end reads
|
|
645
|
+
the trigger. Any other `client:*` falls through as a prop (a prop-check error). */
|
|
646
|
+
const clientAttr = attrs.find(
|
|
647
|
+
(attr) =>
|
|
648
|
+
attr.kind === 'static' &&
|
|
649
|
+
(attr.name === 'client:idle' || attr.name === 'client:visible'),
|
|
650
|
+
)
|
|
651
|
+
const clientTrigger =
|
|
652
|
+
clientAttr?.kind === 'static'
|
|
653
|
+
? (clientAttr.name.slice('client:'.length) as 'idle' | 'visible')
|
|
654
|
+
: undefined
|
|
643
655
|
return {
|
|
644
656
|
kind: 'component',
|
|
645
657
|
name: tag,
|
|
646
658
|
loc: baseOffset + tagStart,
|
|
647
|
-
props: toProps(attrs),
|
|
659
|
+
props: toProps(attrs.filter((attr) => attr !== clientAttr)),
|
|
648
660
|
children: slotted,
|
|
661
|
+
clientTrigger,
|
|
649
662
|
}
|
|
650
663
|
}
|
|
651
664
|
/* `{...expr}` spreads onto a component (its props) or a native element (its
|
|
@@ -80,6 +80,11 @@ export type TemplateNode =
|
|
|
80
80
|
the prop name's source offset — the anchor for an excess-prop diagnostic. */
|
|
81
81
|
props: { name: string; code: string; loc?: number; nameLoc?: number; spread?: boolean }[]
|
|
82
82
|
children: TemplateNode[]
|
|
83
|
+
/* `client:idle` / `client:visible` on the tag → hydrate this child as an island
|
|
84
|
+
(server markup kept, build deferred to the trigger). Extracted from the attributes
|
|
85
|
+
so it never becomes a prop; the build back-end passes it to `mountChild`, SSR and
|
|
86
|
+
the prop check ignore it (an island renders fully on the server). */
|
|
87
|
+
clientTrigger?: 'idle' | 'visible'
|
|
83
88
|
}
|
|
84
89
|
| { kind: 'switch'; subject: string; children: TemplateNode[]; loc?: number }
|
|
85
90
|
/* A branch of a `<template switch>` (`match` set) or `<template if>` chain. Inside an
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { activeCacheStore } from '../shared/activeCacheStore.ts'
|
|
2
2
|
import { cacheKeyOf } from '../shared/cacheKeyOf.ts'
|
|
3
|
+
import { DEFER_MIN_ARRAY_LENGTH } from '../shared/DEFER_MIN_ARRAY_LENGTH.ts'
|
|
3
4
|
import { snapshotShippable } from '../shared/snapshotShippable.ts'
|
|
4
5
|
import type { DeferMarker, ResumeEntry } from './runtime/RESUME.ts'
|
|
5
6
|
|
|
@@ -24,6 +25,12 @@ export function deferResume(promise: unknown, value: unknown): ResumeEntry | Def
|
|
|
24
25
|
if (entry === undefined || !snapshotShippable(entry)) {
|
|
25
26
|
return { ok: true, value }
|
|
26
27
|
}
|
|
28
|
+
/* Only defer a genuinely large grid: below the threshold, inline the value and hydrate
|
|
29
|
+
eagerly — the block is interactive from the first frame, no inert phase, no wake. This is
|
|
30
|
+
what keeps a modest searchable list live instead of frozen-until-idle (the reported gap). */
|
|
31
|
+
if (!Array.isArray(value) || value.length < DEFER_MIN_ARRAY_LENGTH) {
|
|
32
|
+
return { ok: true, value }
|
|
33
|
+
}
|
|
27
34
|
entry.deferred = true
|
|
28
35
|
return { defer: true, key }
|
|
29
36
|
}
|
|
@@ -7,12 +7,14 @@ import { RANGE_CLOSE, RANGE_OPEN } from '../runtime/RANGE_MARKER.ts'
|
|
|
7
7
|
import { RENDER } from '../runtime/RENDER.ts'
|
|
8
8
|
import type { DeferMarker, ResumeEntry } from '../runtime/RESUME.ts'
|
|
9
9
|
import { RESUME } from '../runtime/RESUME.ts'
|
|
10
|
+
import { scheduleWake } from '../runtime/scheduleWake.ts'
|
|
10
11
|
import { scope } from '../runtime/scope.ts'
|
|
11
12
|
import { scopeGroup } from '../runtime/scopeGroup.ts'
|
|
12
13
|
import type { State } from '../runtime/types/State.ts'
|
|
13
14
|
import { state } from '../state.ts'
|
|
14
15
|
import { buildDetachedRange } from './buildDetachedRange.ts'
|
|
15
16
|
import { discardBoundary } from './discardBoundary.ts'
|
|
17
|
+
import { firstElementBetween } from './firstElementBetween.ts'
|
|
16
18
|
import { removeRange } from './removeRange.ts'
|
|
17
19
|
|
|
18
20
|
/*
|
|
@@ -58,11 +60,20 @@ export function awaitBlock(
|
|
|
58
60
|
let active: { start: Comment; end: Comment; dispose: () => void } | undefined
|
|
59
61
|
let anchor: Node | undefined
|
|
60
62
|
let first = true
|
|
63
|
+
/* A deferred (inert-adopted) block schedules a wake (idle or visible); cancel it if the
|
|
64
|
+
owner tears the block out before it fires, so no late re-run touches a detached anchor. */
|
|
65
|
+
let cancelWake: (() => void) | undefined
|
|
61
66
|
/* Bumped each run so a prior run's in-flight promise can't clobber a newer one, AND on
|
|
62
67
|
owner teardown so an in-flight promise that settles AFTER the enclosing `{#if}`/
|
|
63
68
|
`{#for}`/component tears this block out is abandoned — otherwise its settle runs
|
|
64
|
-
`place` on the block's now-detached anchor and `insertBefore` throws NotFoundError.
|
|
65
|
-
|
|
69
|
+
`place` on the block's now-detached anchor and `insertBefore` throws NotFoundError.
|
|
70
|
+
The teardown also cancels a pending wake (above). */
|
|
71
|
+
const guard = generationGuard(() => cancelWake?.())
|
|
72
|
+
/* Flipped by a deferred block's wake. Read at the top of the effect so the block subscribes
|
|
73
|
+
to it and re-runs when it flips — the same re-run path an invalidate takes, so wake needs
|
|
74
|
+
no bespoke build logic. Never flips for a non-deferred block, so it costs one inert read
|
|
75
|
+
per run there. */
|
|
76
|
+
const woken = state(false)
|
|
66
77
|
/* The resolved value, held as a reactive cell so the then-branch reads it through its
|
|
67
78
|
own effects. A re-run that resolves to a NEW value SETS this cell instead of rebuilding
|
|
68
79
|
the branch — the branch (and any keyed `each` inside it) survives and updates in place,
|
|
@@ -215,6 +226,17 @@ export function awaitBlock(
|
|
|
215
226
|
/* Subscribe without a value read: no clone, no decode, no fetch — just the key, so
|
|
216
227
|
cache.invalidate re-runs this effect (createSubscriber ties it to the running scope). */
|
|
217
228
|
activeCacheStore().subscribe(key)
|
|
229
|
+
/* Wake the inert branch — it's a boot-frame optimization, never a lasting mode. The
|
|
230
|
+
wake flips `woken`, re-running the effect through its normal `render` path: the
|
|
231
|
+
lazily-seeded cache reads warm, so the branch materializes live and interactive with
|
|
232
|
+
no fetch. Closes the "deferred grid stays inert until invalidate" gap.
|
|
233
|
+
|
|
234
|
+
Position picks the trigger ('auto'): a branch with an element wakes on VISIBLE (a
|
|
235
|
+
below-the-fold grid decodes only when scrolled to, one never reached costs nothing);
|
|
236
|
+
an empty branch or a DOM with no observer wakes on IDLE — never lingering inert. */
|
|
237
|
+
cancelWake = scheduleWake('auto', firstElementBetween(firstKept, close), () => {
|
|
238
|
+
woken.value = true
|
|
239
|
+
})
|
|
218
240
|
}
|
|
219
241
|
|
|
220
242
|
/* Discard the SSR boundary and (re)build the block from the live promise, fresh
|
|
@@ -332,6 +354,9 @@ export function awaitBlock(
|
|
|
332
354
|
}
|
|
333
355
|
|
|
334
356
|
effect(() => {
|
|
357
|
+
/* Subscribe to the wake cell every run (including the first, before it can flip) so a
|
|
358
|
+
deferred block's idle wake re-runs this effect. Inert for non-deferred blocks. */
|
|
359
|
+
woken.value
|
|
335
360
|
guard.renew()
|
|
336
361
|
if (first) {
|
|
337
362
|
first = false
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { isElement } from './isElement.ts'
|
|
2
|
+
|
|
3
|
+
/* The first Element in the sibling run `[start, end)` — the node a visible-wake observes for a
|
|
4
|
+
deferred region (await branch or component island). Undefined when the run holds no element
|
|
5
|
+
(text/comment only), so the caller falls back to an idle wake. Element detection is
|
|
6
|
+
method-based (`isElement`), not `nodeType`, so the walk runs under the test mini-dom too. */
|
|
7
|
+
export function firstElementBetween(start: Node | null, end: Node | null): Element | undefined {
|
|
8
|
+
for (let node = start; node !== null && node !== end; node = node.nextSibling) {
|
|
9
|
+
if (isElement(node)) {
|
|
10
|
+
return node
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return undefined
|
|
14
|
+
}
|
|
@@ -29,16 +29,19 @@ export function mountChild(
|
|
|
29
29
|
props: Parameters<UiComponent>[1],
|
|
30
30
|
before: Node | null = null,
|
|
31
31
|
label?: string,
|
|
32
|
+
/* `client:idle` / `client:visible` on the tag → hydrate this child as an island (server
|
|
33
|
+
markup kept, build deferred to the trigger). Undefined = hydrate eagerly, as before. */
|
|
34
|
+
clientTrigger?: 'idle' | 'visible',
|
|
32
35
|
): void {
|
|
33
36
|
const moduleId = factory.__abideId
|
|
34
37
|
if (!__ABIDE_DEV__ || !hotReloadEnabled.current || moduleId === undefined) {
|
|
35
|
-
mountRange(parent, factory.build, props, before, label)
|
|
38
|
+
mountRange(parent, factory.build, props, before, label, clientTrigger)
|
|
36
39
|
return
|
|
37
40
|
}
|
|
38
41
|
/* Capture the component's model alongside its mount handle, so a later swap can
|
|
39
42
|
carry its state across (see `hotReplace`). */
|
|
40
43
|
const { value: handle, model } = captureModelDoc(() =>
|
|
41
|
-
mountRange(parent, factory.build, props, before, label),
|
|
44
|
+
mountRange(parent, factory.build, props, before, label, clientTrigger),
|
|
42
45
|
)
|
|
43
46
|
const instance = {
|
|
44
47
|
factory,
|
|
@@ -1,9 +1,15 @@
|
|
|
1
|
+
import { claimChild } from '../runtime/claimChild.ts'
|
|
1
2
|
import { RANGE_CLOSE, RANGE_OPEN } from '../runtime/RANGE_MARKER.ts'
|
|
2
3
|
import { RENDER } from '../runtime/RENDER.ts'
|
|
4
|
+
import { scheduleWake } from '../runtime/scheduleWake.ts'
|
|
3
5
|
import { scope } from '../runtime/scope.ts'
|
|
4
6
|
import type { UiProps } from '../runtime/types/UiProps.ts'
|
|
7
|
+
import { clearBetween } from './clearBetween.ts'
|
|
8
|
+
import { commentData } from './commentData.ts'
|
|
5
9
|
import { disposeRange } from './disposeRange.ts'
|
|
6
10
|
import { fillRange } from './fillRange.ts'
|
|
11
|
+
import { firstElementBetween } from './firstElementBetween.ts'
|
|
12
|
+
import { markerDepthDelta } from './markerDepthDelta.ts'
|
|
7
13
|
import { openMarker } from './openMarker.ts'
|
|
8
14
|
import { withScope } from './withScope.ts'
|
|
9
15
|
|
|
@@ -22,6 +28,12 @@ marker — mirrors `mountSlot`/`when`). `before` (a skeleton-located node, the b
|
|
|
22
28
|
`anchorCursor`) places the range among static siblings on create; hydrate ignores it
|
|
23
29
|
(the claim cursor drives placement). Returns the markers + a disposer so the hot path
|
|
24
30
|
can rebuild in place.
|
|
31
|
+
|
|
32
|
+
`clientTrigger` marks the component an ISLAND: on hydrate its server markup is kept
|
|
33
|
+
verbatim and its build is SKIPPED, then run on the trigger (`'idle'`/`'visible'`) —
|
|
34
|
+
so a below-the-fold widget ships as HTML and wires no effects until scrolled to (or
|
|
35
|
+
the first idle gap), off the critical boot path. Create ignores it (there is no server
|
|
36
|
+
markup to keep — an island is a hydration-cost optimization). See `adoptIsland`.
|
|
25
37
|
*/
|
|
26
38
|
// @documentation plumbing
|
|
27
39
|
export function mountRange(
|
|
@@ -30,6 +42,7 @@ export function mountRange(
|
|
|
30
42
|
props: UiProps | undefined,
|
|
31
43
|
before: Node | null = null,
|
|
32
44
|
label: string | undefined = undefined,
|
|
45
|
+
clientTrigger: 'idle' | 'visible' | undefined = undefined,
|
|
33
46
|
): { start: Comment; end: Comment; dispose: () => void } {
|
|
34
47
|
const hydration = RENDER.hydration
|
|
35
48
|
const start = openMarker(parent, RANGE_OPEN, before)
|
|
@@ -37,6 +50,9 @@ export function mountRange(
|
|
|
37
50
|
const end = openMarker(parent, RANGE_CLOSE, before)
|
|
38
51
|
return fillRange(start, end, build, props, label)
|
|
39
52
|
}
|
|
53
|
+
if (clientTrigger !== undefined) {
|
|
54
|
+
return adoptIsland(parent, start, build, props, label, clientTrigger, hydration)
|
|
55
|
+
}
|
|
40
56
|
/* Hydrate: adopt the server range in place. Establish the child's lexical scope
|
|
41
57
|
and render pass (same as `fillRange`), build claiming the existing nodes, then
|
|
42
58
|
claim the end marker the build's content stops before. */
|
|
@@ -44,3 +60,62 @@ export function mountRange(
|
|
|
44
60
|
const end = openMarker(parent, RANGE_CLOSE)
|
|
45
61
|
return { start, end, dispose: disposeRange(scoped, start, end) }
|
|
46
62
|
}
|
|
63
|
+
|
|
64
|
+
/*
|
|
65
|
+
Island hydration: keep the server markup, skip the build, wake it later on the trigger. Scan the
|
|
66
|
+
child's server nodes to THIS range's depth-matched close (`markerDepthDelta` balances nested
|
|
67
|
+
`[`…`]` / `abide:` ranges) — keeping them and advancing the claim cursor past them, so the rest
|
|
68
|
+
of the page hydrates around the skipped subtree — then claim the close marker. No scope is built,
|
|
69
|
+
so the inert disposer only clears the kept nodes; the wake clears them and builds the child fresh
|
|
70
|
+
(hydration off) between the same markers, exactly like a create-path mount.
|
|
71
|
+
*/
|
|
72
|
+
function adoptIsland(
|
|
73
|
+
parent: Node,
|
|
74
|
+
start: Comment,
|
|
75
|
+
build: (host: Node, props?: UiProps) => void,
|
|
76
|
+
props: UiProps | undefined,
|
|
77
|
+
label: string | undefined,
|
|
78
|
+
trigger: 'idle' | 'visible',
|
|
79
|
+
hydration: NonNullable<(typeof RENDER)['hydration']>,
|
|
80
|
+
): { start: Comment; end: Comment; dispose: () => void } {
|
|
81
|
+
const firstKept = claimChild(hydration, parent)
|
|
82
|
+
let node = firstKept
|
|
83
|
+
let depth = 0
|
|
84
|
+
while (node !== null) {
|
|
85
|
+
const data = commentData(node)
|
|
86
|
+
if (data !== undefined) {
|
|
87
|
+
const delta = markerDepthDelta(data)
|
|
88
|
+
/* A depth-0 close is THIS range's own; anything nested balances out first. */
|
|
89
|
+
if (delta === -1 && depth === 0) {
|
|
90
|
+
break
|
|
91
|
+
}
|
|
92
|
+
depth += delta
|
|
93
|
+
}
|
|
94
|
+
node = node.nextSibling
|
|
95
|
+
}
|
|
96
|
+
hydration.next.set(parent, node)
|
|
97
|
+
const end = openMarker(parent, RANGE_CLOSE)
|
|
98
|
+
|
|
99
|
+
/* Inert: no scope/effects for the kept nodes, so disposal only evicts the range. The wake
|
|
100
|
+
swaps in a real build whose own disposer replaces this one. */
|
|
101
|
+
let disposeContent = (): void => clearBetween(start, end)
|
|
102
|
+
const wake = (): void => {
|
|
103
|
+
disposeContent()
|
|
104
|
+
const previous = RENDER.hydration
|
|
105
|
+
RENDER.hydration = undefined
|
|
106
|
+
try {
|
|
107
|
+
disposeContent = fillRange(start, end, build, props, label).dispose
|
|
108
|
+
} finally {
|
|
109
|
+
RENDER.hydration = previous
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const cancelWake = scheduleWake(trigger, firstElementBetween(firstKept, end), wake)
|
|
113
|
+
return {
|
|
114
|
+
start,
|
|
115
|
+
end,
|
|
116
|
+
dispose: () => {
|
|
117
|
+
cancelWake()
|
|
118
|
+
disposeContent()
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { snippet } from '../shared/snippet.ts'
|
|
2
|
+
import { deferResume } from './deferResume.ts'
|
|
2
3
|
import { anchorCursor } from './dom/anchorCursor.ts'
|
|
3
4
|
import { appendSnippet } from './dom/appendSnippet.ts'
|
|
4
5
|
import { appendStatic } from './dom/appendStatic.ts'
|
|
@@ -25,7 +26,6 @@ import { spreadProps } from './dom/spreadProps.ts'
|
|
|
25
26
|
import { switchBlock } from './dom/switchBlock.ts'
|
|
26
27
|
import { tryBlock } from './dom/tryBlock.ts'
|
|
27
28
|
import { when } from './dom/when.ts'
|
|
28
|
-
import { deferResume } from './deferResume.ts'
|
|
29
29
|
import { effect } from './effect.ts'
|
|
30
30
|
import { enterScope } from './enterScope.ts'
|
|
31
31
|
import { exitScope } from './exitScope.ts'
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { whenIdle } from './whenIdle.ts'
|
|
2
|
+
import { whenVisible } from './whenVisible.ts'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
Schedule a one-shot wake for a deferred (inert-hydrated) region by trigger — the single
|
|
6
|
+
trigger-selection both the await block and component islands share. `'visible'` (and `'auto'`)
|
|
7
|
+
wake on scroll-in when the DOM can be measured (a real IntersectionObserver) and the region has
|
|
8
|
+
an element to observe: a below-the-fold region decodes only when reached, one never scrolled to
|
|
9
|
+
costs nothing. Otherwise — `'idle'`, no observer, or an empty branch — wake on idle: off the
|
|
10
|
+
critical boot path but soon, so the region never lingers inert. Returns a cancel to drop a
|
|
11
|
+
pending wake on teardown.
|
|
12
|
+
|
|
13
|
+
`'auto'` is the await block's policy (defer decides, position decides trigger); `'idle'` /
|
|
14
|
+
`'visible'` are an island's explicit `client:` choice.
|
|
15
|
+
*/
|
|
16
|
+
export function scheduleWake(
|
|
17
|
+
trigger: 'idle' | 'visible' | 'auto',
|
|
18
|
+
element: Element | undefined,
|
|
19
|
+
wake: () => void,
|
|
20
|
+
): () => void {
|
|
21
|
+
const hasObserver =
|
|
22
|
+
typeof (globalThis as { IntersectionObserver?: unknown }).IntersectionObserver ===
|
|
23
|
+
'function'
|
|
24
|
+
if (trigger !== 'idle' && hasObserver && element !== undefined) {
|
|
25
|
+
return whenVisible(element, wake)
|
|
26
|
+
}
|
|
27
|
+
return whenIdle(wake)
|
|
28
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Run `callback` once, in the first idle gap after the current frame — the client-side
|
|
3
|
+
trigger a deferred (inert-hydrated) region uses to wake itself: boot stays cheap (no
|
|
4
|
+
decode, no effects), then the region belatedly comes alive off the critical path,
|
|
5
|
+
before a human can act on it. `requestIdleCallback` when the runtime has it (a `timeout`
|
|
6
|
+
guarantees it fires even under sustained load); a `setTimeout(0)` macrotask otherwise
|
|
7
|
+
(Safari < 16.4, test DOMs), which still clears the boot frame. Returns a cancel to drop
|
|
8
|
+
the pending wake on teardown, so a region torn down before idle does no late work.
|
|
9
|
+
*/
|
|
10
|
+
export function whenIdle(callback: () => void, timeoutMs = 200): () => void {
|
|
11
|
+
const globalWithIdle = globalThis as {
|
|
12
|
+
requestIdleCallback?: (cb: () => void, options?: { timeout: number }) => number
|
|
13
|
+
cancelIdleCallback?: (handle: number) => void
|
|
14
|
+
}
|
|
15
|
+
if (typeof globalWithIdle.requestIdleCallback === 'function') {
|
|
16
|
+
const handle = globalWithIdle.requestIdleCallback(callback, { timeout: timeoutMs })
|
|
17
|
+
return () => globalWithIdle.cancelIdleCallback?.(handle)
|
|
18
|
+
}
|
|
19
|
+
const handle = setTimeout(callback, 0)
|
|
20
|
+
return () => clearTimeout(handle)
|
|
21
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Run `callback` once, the first time `element` enters (or is already within) the viewport —
|
|
3
|
+
the trigger a below-the-fold deferred region uses to wake only when scrolled into view, so a
|
|
4
|
+
grid the user never reaches costs nothing. A synchronous fire where the observer is absent
|
|
5
|
+
(SSR/test DOMs), which keeps the region from staying inert where visibility can't be measured.
|
|
6
|
+
Returns a cancel to drop a pending watch on teardown. Fires at most once.
|
|
7
|
+
|
|
8
|
+
ONE shared IntersectionObserver backs every watcher (a grid of N islands registers N elements
|
|
9
|
+
on one observer, not N observers), and wakes are BATCHED off the observer callback into a
|
|
10
|
+
frame-flush capped at `WAKE_BUDGET_PER_FRAME`: a wake rebuilds DOM, so running it inside the
|
|
11
|
+
observer callback would force a synchronous layout that shifts other elements across the
|
|
12
|
+
margin and re-fires the observer — a cascade that locks the main thread at grid scale. Queuing
|
|
13
|
+
to a rAF and spending a bounded number per frame breaks that loop and keeps each frame cheap;
|
|
14
|
+
overflow spills to the next frame, so even a whole grid scrolled into view at once stays
|
|
15
|
+
responsive.
|
|
16
|
+
*/
|
|
17
|
+
// A screenful of margin so a region is live by the time it's actually read.
|
|
18
|
+
const ROOT_MARGIN = '256px'
|
|
19
|
+
// Max wakes (DOM rebuilds) per frame — the ceiling that keeps a flush off the critical path.
|
|
20
|
+
const WAKE_BUDGET_PER_FRAME = 40
|
|
21
|
+
|
|
22
|
+
let sharedObserver: IntersectionObserver | undefined
|
|
23
|
+
/* The IntersectionObserver constructor `sharedObserver` was built from. Identity only changes
|
|
24
|
+
when a test swaps `globalThis.IntersectionObserver`; on a change the observer + its pending
|
|
25
|
+
registrations are rebuilt, so module state doesn't leak across tests. */
|
|
26
|
+
let sharedObserverCtor: unknown
|
|
27
|
+
const wakeByElement = new Map<Element, () => void>()
|
|
28
|
+
const readyWakes = new Set<() => void>()
|
|
29
|
+
let flushScheduled = false
|
|
30
|
+
|
|
31
|
+
/* Next animation frame (browser) or macrotask (test/SSR) — the flush cadence. */
|
|
32
|
+
function scheduleFrame(run: () => void): void {
|
|
33
|
+
const globalWithRaf = globalThis as { requestAnimationFrame?: (cb: () => void) => number }
|
|
34
|
+
if (typeof globalWithRaf.requestAnimationFrame === 'function') {
|
|
35
|
+
globalWithRaf.requestAnimationFrame(run)
|
|
36
|
+
} else {
|
|
37
|
+
setTimeout(run, 0)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/* Spend up to the per-frame budget of queued wakes; reschedule if any remain. */
|
|
42
|
+
function flushWakes(): void {
|
|
43
|
+
flushScheduled = false
|
|
44
|
+
let processed = 0
|
|
45
|
+
for (const wake of readyWakes) {
|
|
46
|
+
readyWakes.delete(wake)
|
|
47
|
+
wake()
|
|
48
|
+
processed += 1
|
|
49
|
+
if (processed >= WAKE_BUDGET_PER_FRAME) {
|
|
50
|
+
break
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
scheduleFlush()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function scheduleFlush(): void {
|
|
57
|
+
if (flushScheduled || readyWakes.size === 0) {
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
flushScheduled = true
|
|
61
|
+
scheduleFrame(flushWakes)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/* Queue every newly-intersecting element's wake — never run it here (see the cascade note). */
|
|
65
|
+
function onIntersections(entries: IntersectionObserverEntry[]): void {
|
|
66
|
+
for (const entry of entries) {
|
|
67
|
+
if (entry.isIntersecting) {
|
|
68
|
+
const wake = wakeByElement.get(entry.target)
|
|
69
|
+
if (wake !== undefined) {
|
|
70
|
+
wakeByElement.delete(entry.target)
|
|
71
|
+
sharedObserver?.unobserve(entry.target)
|
|
72
|
+
readyWakes.add(wake)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
scheduleFlush()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function observerFor(observerConstructor: typeof IntersectionObserver): IntersectionObserver {
|
|
80
|
+
if (sharedObserver === undefined || sharedObserverCtor !== observerConstructor) {
|
|
81
|
+
wakeByElement.clear()
|
|
82
|
+
readyWakes.clear()
|
|
83
|
+
sharedObserver = new observerConstructor(onIntersections, { rootMargin: ROOT_MARGIN })
|
|
84
|
+
sharedObserverCtor = observerConstructor
|
|
85
|
+
}
|
|
86
|
+
return sharedObserver
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function whenVisible(element: Element, callback: () => void): () => void {
|
|
90
|
+
const observerConstructor = (
|
|
91
|
+
globalThis as { IntersectionObserver?: typeof IntersectionObserver }
|
|
92
|
+
).IntersectionObserver
|
|
93
|
+
if (typeof observerConstructor !== 'function') {
|
|
94
|
+
callback()
|
|
95
|
+
return () => undefined
|
|
96
|
+
}
|
|
97
|
+
const observer = observerFor(observerConstructor)
|
|
98
|
+
wakeByElement.set(element, callback)
|
|
99
|
+
observer.observe(element)
|
|
100
|
+
return () => {
|
|
101
|
+
wakeByElement.delete(element)
|
|
102
|
+
readyWakes.delete(callback)
|
|
103
|
+
observer.unobserve(element)
|
|
104
|
+
}
|
|
105
|
+
}
|