@wcstack/raf 1.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/README.ja.md +176 -0
- package/README.md +177 -0
- package/dist/auto.js +3 -0
- package/dist/auto.min.js +3 -0
- package/dist/index.d.ts +233 -0
- package/dist/index.esm.js +698 -0
- package/dist/index.esm.js.map +1 -0
- package/dist/index.esm.min.js +2 -0
- package/dist/index.esm.min.js.map +1 -0
- package/package.json +73 -0
|
@@ -0,0 +1,698 @@
|
|
|
1
|
+
const _config = {
|
|
2
|
+
autoTrigger: true,
|
|
3
|
+
triggerAttribute: "data-raftarget",
|
|
4
|
+
tagNames: {
|
|
5
|
+
raf: "wcs-raf",
|
|
6
|
+
},
|
|
7
|
+
};
|
|
8
|
+
function deepFreeze(obj) {
|
|
9
|
+
if (obj === null || typeof obj !== "object")
|
|
10
|
+
return obj;
|
|
11
|
+
Object.freeze(obj);
|
|
12
|
+
for (const key of Object.keys(obj)) {
|
|
13
|
+
deepFreeze(obj[key]);
|
|
14
|
+
}
|
|
15
|
+
return obj;
|
|
16
|
+
}
|
|
17
|
+
function deepClone(obj) {
|
|
18
|
+
if (obj === null || typeof obj !== "object")
|
|
19
|
+
return obj;
|
|
20
|
+
const clone = {};
|
|
21
|
+
for (const key of Object.keys(obj)) {
|
|
22
|
+
clone[key] = deepClone(obj[key]);
|
|
23
|
+
}
|
|
24
|
+
return clone;
|
|
25
|
+
}
|
|
26
|
+
let frozenConfig = null;
|
|
27
|
+
// Internal-only live handle to the mutable config. NOT part of the public API
|
|
28
|
+
// (deliberately absent from exports.ts) — it is exported solely so sibling
|
|
29
|
+
// modules in this package can read current settings cheaply. External consumers
|
|
30
|
+
// must use getConfig() (returns a deep-frozen snapshot) / setConfig(). Mutating
|
|
31
|
+
// this object directly bypasses the frozenConfig cache and is unsupported.
|
|
32
|
+
const config = _config;
|
|
33
|
+
function getConfig() {
|
|
34
|
+
if (!frozenConfig) {
|
|
35
|
+
frozenConfig = deepFreeze(deepClone(_config));
|
|
36
|
+
}
|
|
37
|
+
return frozenConfig;
|
|
38
|
+
}
|
|
39
|
+
function setConfig(partialConfig) {
|
|
40
|
+
if (typeof partialConfig.autoTrigger === "boolean") {
|
|
41
|
+
_config.autoTrigger = partialConfig.autoTrigger;
|
|
42
|
+
}
|
|
43
|
+
if (typeof partialConfig.triggerAttribute === "string") {
|
|
44
|
+
_config.triggerAttribute = partialConfig.triggerAttribute;
|
|
45
|
+
}
|
|
46
|
+
if (partialConfig.tagNames) {
|
|
47
|
+
Object.assign(_config.tagNames, partialConfig.tagNames);
|
|
48
|
+
}
|
|
49
|
+
frozenConfig = null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Headless requestAnimationFrame primitive — `TimerCore`'s sibling with the
|
|
54
|
+
* time source swapped from `setInterval` (a period) to rAF (the browser's
|
|
55
|
+
* rendering opportunity). Exposed through the wc-bindable protocol: it streams
|
|
56
|
+
* `tick` (frame counter), `elapsed` (accumulated ACTIVE milliseconds), `dt`
|
|
57
|
+
* (delta to the previous frame) and the `running` / `suspended` pair, and is
|
|
58
|
+
* driven by the `start` / `stop` / `reset` / `pause` / `resume` commands.
|
|
59
|
+
*
|
|
60
|
+
* `tick` / `elapsed` / `dt` are all surfaced via the single `wcs-raf:tick`
|
|
61
|
+
* event (read through getters, mirroring how FetchCore exposes value/status
|
|
62
|
+
* from one `wcs-fetch:response` event).
|
|
63
|
+
*
|
|
64
|
+
* Contracts specific to this node (docs/raf-tag-design.md):
|
|
65
|
+
*
|
|
66
|
+
* - **dt describes continuous running only.** The first frame after `start()`,
|
|
67
|
+
* `resume()`, or a visibility interruption reports `dt = 0` — a value that
|
|
68
|
+
* spans an interruption never reaches observers. Like `suspended`, the
|
|
69
|
+
* visibility boundary is only detected once observe() has subscribed to
|
|
70
|
+
* `visibilitychange`; a headless setup that skips observe() will see the
|
|
71
|
+
* raw spanning delta on the first frame after a hidden gap. There is
|
|
72
|
+
* deliberately NO upper clamp: how to treat a slow frame is the consumer's
|
|
73
|
+
* domain decision.
|
|
74
|
+
* - **elapsed is Σdt (active time).** Because interruption-spanning deltas are
|
|
75
|
+
* normalized to 0, summing dt yields exactly the time frames were actually
|
|
76
|
+
* being delivered — no separate segment bookkeeping is needed, and hidden /
|
|
77
|
+
* paused periods contribute nothing. Granularity is one frame: between
|
|
78
|
+
* frames the getter returns the value as of the last tick.
|
|
79
|
+
* - **running / suspended are a desired/actual pair** (the wakelock split): in
|
|
80
|
+
* a hidden tab the browser delivers no frames at all, so `running` (the
|
|
81
|
+
* started intent) stays true while `suspended` reports that delivery is
|
|
82
|
+
* actually stopped. `suspended` is only meaningful after `observe()` has
|
|
83
|
+
* subscribed to `visibilitychange`; without a document it stays false.
|
|
84
|
+
* - **No `error` surface.** rAF has no persistent failure mode; on a platform
|
|
85
|
+
* without it, `start()` is a silent no-op (never-throw, resize precedent).
|
|
86
|
+
*/
|
|
87
|
+
class RafCore extends EventTarget {
|
|
88
|
+
static wcBindable = {
|
|
89
|
+
protocol: "wc-bindable",
|
|
90
|
+
version: 1,
|
|
91
|
+
properties: [
|
|
92
|
+
{ name: "tick", event: "wcs-raf:tick", getter: (e) => e.detail.count },
|
|
93
|
+
{ name: "elapsed", event: "wcs-raf:tick", getter: (e) => e.detail.elapsed },
|
|
94
|
+
{ name: "dt", event: "wcs-raf:tick", getter: (e) => e.detail.dt },
|
|
95
|
+
{ name: "running", event: "wcs-raf:running-changed" },
|
|
96
|
+
{ name: "suspended", event: "wcs-raf:suspended-changed" },
|
|
97
|
+
],
|
|
98
|
+
commands: [
|
|
99
|
+
{ name: "start" },
|
|
100
|
+
{ name: "stop" },
|
|
101
|
+
{ name: "reset" },
|
|
102
|
+
{ name: "pause" },
|
|
103
|
+
{ name: "resume" },
|
|
104
|
+
],
|
|
105
|
+
};
|
|
106
|
+
_target;
|
|
107
|
+
_injectedScheduler;
|
|
108
|
+
_handle = null;
|
|
109
|
+
// Lazily-created wrapper around the global rAF pair, cached so the hot
|
|
110
|
+
// frame-reschedule path (_frame, once per delivered frame) does not
|
|
111
|
+
// allocate a new object + closures every call. `request`/`cancel` still
|
|
112
|
+
// dereference `globalThis.requestAnimationFrame` / `cancelAnimationFrame`
|
|
113
|
+
// live on every invocation (they are not snapshotted here), so call-time
|
|
114
|
+
// resolution (§3.7) is unchanged — only the wrapper object itself is
|
|
115
|
+
// reused once the global functions are first found present.
|
|
116
|
+
_globalScheduler = null;
|
|
117
|
+
// Generation guard (§3.4): a monotonic arming counter. Bumped when a run is
|
|
118
|
+
// armed (start()/resume()), when an armed handle is cancelled
|
|
119
|
+
// (_clearHandle()) and on dispose(). _requestFrame() captures the value in
|
|
120
|
+
// each request's closure and drops the frame if it no longer matches the
|
|
121
|
+
// live field when it fires. cancel() is best-effort against a non-compliant
|
|
122
|
+
// scheduler; the captured generation is the guarantee — a stale callback can
|
|
123
|
+
// neither mutate state, dispatch on a torn-down element, nor corrupt a
|
|
124
|
+
// newer run's `_handle` bookkeeping. A live-field comparison (the previous
|
|
125
|
+
// `_runGen` scheme) could not survive a dispose() → start() round trip: the
|
|
126
|
+
// new start() re-synced the pair and let the stale callback through,
|
|
127
|
+
// permanently doubling the frame loop.
|
|
128
|
+
_gen = 0;
|
|
129
|
+
// SSR (§3.8): there is no asynchronous probe, so readiness is immediate.
|
|
130
|
+
_ready = Promise.resolve();
|
|
131
|
+
_tick = 0;
|
|
132
|
+
_dt = 0;
|
|
133
|
+
_elapsed = 0;
|
|
134
|
+
_running = false;
|
|
135
|
+
_suspended = false;
|
|
136
|
+
_paused = false;
|
|
137
|
+
// Timestamp of the previous frame within the current continuous run.
|
|
138
|
+
// `null` means "the next frame starts a run segment": its dt is reported as
|
|
139
|
+
// 0 (the G3 normalization). Cleared at start()/resume() and on every
|
|
140
|
+
// visibilitychange (an interruption boundary).
|
|
141
|
+
_lastTs = null;
|
|
142
|
+
// `_tick` value captured at the start of the current run. `repeat` counts
|
|
143
|
+
// frames *per run*, so the stop condition compares against this baseline
|
|
144
|
+
// rather than the cumulative `_tick` (which only resets on reset()).
|
|
145
|
+
_repeat = 0;
|
|
146
|
+
_runStartTick = 0;
|
|
147
|
+
// The document whose visibility drives `suspended`, subscribed in observe()
|
|
148
|
+
// and released in dispose(). Null before observe() or in non-DOM
|
|
149
|
+
// environments — `suspended` then simply stays false.
|
|
150
|
+
_visibilityDoc = null;
|
|
151
|
+
constructor(target, scheduler) {
|
|
152
|
+
super();
|
|
153
|
+
this._target = target ?? this;
|
|
154
|
+
this._injectedScheduler = scheduler ?? null;
|
|
155
|
+
}
|
|
156
|
+
get tick() {
|
|
157
|
+
return this._tick;
|
|
158
|
+
}
|
|
159
|
+
get elapsed() {
|
|
160
|
+
return this._elapsed;
|
|
161
|
+
}
|
|
162
|
+
get dt() {
|
|
163
|
+
return this._dt;
|
|
164
|
+
}
|
|
165
|
+
get running() {
|
|
166
|
+
return this._running;
|
|
167
|
+
}
|
|
168
|
+
get suspended() {
|
|
169
|
+
return this._suspended;
|
|
170
|
+
}
|
|
171
|
+
// SSR readiness (§3.8): resolves after the first probe. There is nothing to
|
|
172
|
+
// probe, so this is an already-resolved promise.
|
|
173
|
+
get ready() {
|
|
174
|
+
return this._ready;
|
|
175
|
+
}
|
|
176
|
+
// Lifecycle (§3.5). observe() establishes the one ambient subscription this
|
|
177
|
+
// node has — `visibilitychange`, which drives the `suspended` output and the
|
|
178
|
+
// dt=0 normalization across a hidden period. Idempotent; a no-op without a
|
|
179
|
+
// document (SSR pre-pass, worker). dispose() tears everything down and bumps
|
|
180
|
+
// the generation so a frame already queued cannot fire onto a torn-down
|
|
181
|
+
// element.
|
|
182
|
+
observe() {
|
|
183
|
+
if (this._visibilityDoc === null && typeof document !== "undefined") {
|
|
184
|
+
this._visibilityDoc = document;
|
|
185
|
+
document.addEventListener("visibilitychange", this._onVisibilityChange);
|
|
186
|
+
// Sync `suspended` to the visibility state at subscription time: with a
|
|
187
|
+
// start()-before-observe() ordering (headless Core usage) the document
|
|
188
|
+
// may already be hidden, and waiting for the next visibilitychange
|
|
189
|
+
// would report suspended=false until then. Same-value guarded, so the
|
|
190
|
+
// common visible-at-observe case dispatches nothing.
|
|
191
|
+
this._updateSuspended();
|
|
192
|
+
}
|
|
193
|
+
return this._ready;
|
|
194
|
+
}
|
|
195
|
+
dispose() {
|
|
196
|
+
this._gen++;
|
|
197
|
+
this.stop();
|
|
198
|
+
if (this._visibilityDoc !== null) {
|
|
199
|
+
this._visibilityDoc.removeEventListener("visibilitychange", this._onVisibilityChange);
|
|
200
|
+
this._visibilityDoc = null;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// --- State setters with event dispatch ---
|
|
204
|
+
_dispatchTick(timestamp) {
|
|
205
|
+
this._target.dispatchEvent(new CustomEvent("wcs-raf:tick", {
|
|
206
|
+
detail: { count: this._tick, elapsed: this._elapsed, dt: this._dt, timestamp },
|
|
207
|
+
bubbles: true,
|
|
208
|
+
}));
|
|
209
|
+
}
|
|
210
|
+
_setRunning(running) {
|
|
211
|
+
if (this._running === running)
|
|
212
|
+
return;
|
|
213
|
+
this._running = running;
|
|
214
|
+
this._target.dispatchEvent(new CustomEvent("wcs-raf:running-changed", {
|
|
215
|
+
detail: running,
|
|
216
|
+
bubbles: true,
|
|
217
|
+
}));
|
|
218
|
+
// `suspended` is derived from (running && hidden), so every running
|
|
219
|
+
// transition re-evaluates it: stop/pause drop a suspension, and a start()
|
|
220
|
+
// inside an already-hidden tab reports it immediately (honestly: no frame
|
|
221
|
+
// will arrive until the tab is visible again).
|
|
222
|
+
this._updateSuspended();
|
|
223
|
+
}
|
|
224
|
+
_setSuspended(suspended) {
|
|
225
|
+
if (this._suspended === suspended)
|
|
226
|
+
return;
|
|
227
|
+
this._suspended = suspended;
|
|
228
|
+
this._target.dispatchEvent(new CustomEvent("wcs-raf:suspended-changed", {
|
|
229
|
+
detail: suspended,
|
|
230
|
+
bubbles: true,
|
|
231
|
+
}));
|
|
232
|
+
}
|
|
233
|
+
_updateSuspended() {
|
|
234
|
+
const hidden = this._visibilityDoc !== null && this._visibilityDoc.visibilityState === "hidden";
|
|
235
|
+
this._setSuspended(this._running && hidden);
|
|
236
|
+
}
|
|
237
|
+
// --- Public API ---
|
|
238
|
+
start(options = {}) {
|
|
239
|
+
// Idempotent while running: a redundant start() must not stack a second
|
|
240
|
+
// frame loop (which would double the tick rate). Reconfiguring an active
|
|
241
|
+
// run is done via stop() + start().
|
|
242
|
+
if (this._running)
|
|
243
|
+
return;
|
|
244
|
+
// Resolve the platform API at call time (§3.7). Absent rAF (SSR pre-pass,
|
|
245
|
+
// worker) makes start() a silent no-op — never-throw, and this node has no
|
|
246
|
+
// error surface by design.
|
|
247
|
+
const scheduler = this._resolveScheduler();
|
|
248
|
+
if (scheduler === null)
|
|
249
|
+
return;
|
|
250
|
+
// start() begins a fresh run, so clear any lingering pause from a prior
|
|
251
|
+
// pause()-without-resume(). Without this, the loop would run while _paused
|
|
252
|
+
// stayed true, leaving pause() a no-op and letting resume() overwrite the
|
|
253
|
+
// live handle (leak + double fire).
|
|
254
|
+
this._paused = false;
|
|
255
|
+
// `repeat` is per-run intent, NOT persistent configuration: every start()
|
|
256
|
+
// re-establishes it from the options, defaulting to "unlimited" when
|
|
257
|
+
// omitted. This keeps a bare start() after a bounded run from silently
|
|
258
|
+
// inheriting the old bounds.
|
|
259
|
+
this._repeat = (typeof options.repeat === "number" && options.repeat > 0) ? options.repeat : 0;
|
|
260
|
+
// New arming generation (§3.4): invalidates any callback still in flight
|
|
261
|
+
// from a previous run (e.g. one whose cancel() a non-compliant scheduler
|
|
262
|
+
// ignored). Bumped BEFORE the running-changed dispatch below, so that a
|
|
263
|
+
// re-entrant restart from a listener arms with the newest generation —
|
|
264
|
+
// the re-entrancy guard then keeps this outer call from arming (and
|
|
265
|
+
// bumping) on top of it.
|
|
266
|
+
this._gen++;
|
|
267
|
+
this._setRunning(true);
|
|
268
|
+
// Baseline this run's per-run repeat counting (set after _setRunning so a
|
|
269
|
+
// re-start of a completed bounded run fires the full N frames again).
|
|
270
|
+
this._runStartTick = this._tick;
|
|
271
|
+
// G3: the first frame of a run reports dt = 0.
|
|
272
|
+
this._lastTs = null;
|
|
273
|
+
// Re-entrancy guard: _setRunning(true) just dispatched running-changed
|
|
274
|
+
// synchronously, and a listener may have changed the world from inside it.
|
|
275
|
+
// - `!_running`: the listener called stop()/pause()/dispose(). Without
|
|
276
|
+
// this check a "ghost" frame would still be scheduled for an
|
|
277
|
+
// already-stopped run — it would either tick once while running stays
|
|
278
|
+
// false, or leave an uncancellable handle behind.
|
|
279
|
+
// - `_handle !== null`: the listener restarted the loop itself
|
|
280
|
+
// (stop()→start()); the inner start() already armed the new run, and
|
|
281
|
+
// requesting again here would overwrite `_handle` (losing the inner
|
|
282
|
+
// handle, never cancelled) and stack a permanent second frame loop.
|
|
283
|
+
// On the normal path `_handle` is always null here — every transition
|
|
284
|
+
// to `_running === false` clears it — so non-null can only mean a
|
|
285
|
+
// re-entrant listener already scheduled the run for us.
|
|
286
|
+
if (!this._running || this._handle !== null)
|
|
287
|
+
return;
|
|
288
|
+
this._requestFrame(scheduler);
|
|
289
|
+
}
|
|
290
|
+
stop() {
|
|
291
|
+
this._clearHandle();
|
|
292
|
+
this._paused = false;
|
|
293
|
+
this._setRunning(false);
|
|
294
|
+
}
|
|
295
|
+
reset() {
|
|
296
|
+
this._clearHandle();
|
|
297
|
+
this._paused = false;
|
|
298
|
+
this._tick = 0;
|
|
299
|
+
this._elapsed = 0;
|
|
300
|
+
this._dt = 0;
|
|
301
|
+
this._lastTs = null;
|
|
302
|
+
this._setRunning(false);
|
|
303
|
+
// Notify observers that the counter/elapsed/dt have returned to zero. The
|
|
304
|
+
// notification is not a frame, so `timestamp` is 0 (see WcsRafTickDetail).
|
|
305
|
+
this._dispatchTick(0);
|
|
306
|
+
}
|
|
307
|
+
pause() {
|
|
308
|
+
// Pause only a live loop; a no-op otherwise so it composes safely with the
|
|
309
|
+
// declarative lifecycle. Unlike stop(), it records `_paused` so resume()
|
|
310
|
+
// can tell an intentional pause from a full stop. No elapsed bookkeeping
|
|
311
|
+
// is needed: elapsed is Σdt, and the resume boundary's dt is 0.
|
|
312
|
+
if (!this._running || this._paused)
|
|
313
|
+
return;
|
|
314
|
+
this._clearHandle();
|
|
315
|
+
this._paused = true;
|
|
316
|
+
this._setRunning(false);
|
|
317
|
+
}
|
|
318
|
+
resume() {
|
|
319
|
+
if (!this._paused)
|
|
320
|
+
return;
|
|
321
|
+
const scheduler = this._resolveScheduler();
|
|
322
|
+
if (scheduler === null)
|
|
323
|
+
return;
|
|
324
|
+
this._paused = false;
|
|
325
|
+
// New arming generation (§3.4), bumped before the running-changed
|
|
326
|
+
// dispatch for the same re-entrancy reason as start().
|
|
327
|
+
this._gen++;
|
|
328
|
+
this._setRunning(true);
|
|
329
|
+
// G3: the first frame after a pause reports dt = 0 (elapsed therefore does
|
|
330
|
+
// not count the paused period — the "active time" contract).
|
|
331
|
+
this._lastTs = null;
|
|
332
|
+
// Re-entrancy guard, for the same reasons as start() (see the comment
|
|
333
|
+
// there): a running-changed listener may have synchronously stopped this
|
|
334
|
+
// node — or restarted it, leaving `_handle` already armed — from inside
|
|
335
|
+
// _setRunning(true) above.
|
|
336
|
+
if (!this._running || this._handle !== null)
|
|
337
|
+
return;
|
|
338
|
+
this._requestFrame(scheduler);
|
|
339
|
+
}
|
|
340
|
+
// --- Internal ---
|
|
341
|
+
_frame = (timestamp) => {
|
|
342
|
+
// Reached only through _requestFrame's generation-checked closure (§3.4):
|
|
343
|
+
// a stale callback — disposed, cancelled by a non-compliant scheduler, or
|
|
344
|
+
// superseded by a newer run — never gets here.
|
|
345
|
+
this._handle = null;
|
|
346
|
+
// dt: delta to the previous frame within this continuous run; 0 when this
|
|
347
|
+
// frame opens a segment (start/resume/visibility boundary — G3).
|
|
348
|
+
const dt = this._lastTs === null ? 0 : timestamp - this._lastTs;
|
|
349
|
+
this._lastTs = timestamp;
|
|
350
|
+
this._tick++;
|
|
351
|
+
this._dt = dt;
|
|
352
|
+
this._elapsed += dt;
|
|
353
|
+
this._dispatchTick(timestamp);
|
|
354
|
+
// Auto-stop once this run has fired the requested number of frames
|
|
355
|
+
// (repeat=0 runs forever). Counted per-run via `_runStartTick`, so a
|
|
356
|
+
// re-start after a completed bounded run fires N frames again. `once` is
|
|
357
|
+
// expressed by the Shell as repeat=1.
|
|
358
|
+
//
|
|
359
|
+
// The cleanup mirrors stop() exactly, because a tick listener may have
|
|
360
|
+
// synchronously paused — or paused and resumed — DURING the final frame's
|
|
361
|
+
// dispatch above. The run's budget is exhausted either way, so clear the
|
|
362
|
+
// pause (a later resume() must be a no-op, not an N+1th frame) and cancel
|
|
363
|
+
// any handle a re-entrant resume() armed (it would otherwise survive as a
|
|
364
|
+
// ghost frame and tick past the budget). On the normal path both are
|
|
365
|
+
// already clear (no-ops). A stop()→start() restart is NOT affected: the
|
|
366
|
+
// new run re-baselines `_runStartTick`, so this branch is not taken.
|
|
367
|
+
if (this._repeat > 0 && (this._tick - this._runStartTick) >= this._repeat) {
|
|
368
|
+
this._clearHandle();
|
|
369
|
+
this._paused = false;
|
|
370
|
+
this._setRunning(false);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
// Re-request the next frame — unless a tick listener stopped the loop
|
|
374
|
+
// synchronously during the dispatch above, or already scheduled a new run
|
|
375
|
+
// itself (a synchronous stop()→start() / pause()→resume() restart leaves
|
|
376
|
+
// _handle non-null; re-requesting on top of it would stack a permanent
|
|
377
|
+
// second frame loop. The generation guard cannot catch this: a tail
|
|
378
|
+
// request here would capture the restart's own — current — generation
|
|
379
|
+
// and produce a second equally-valid loop).
|
|
380
|
+
if (this._running && this._handle === null) {
|
|
381
|
+
const scheduler = this._resolveScheduler();
|
|
382
|
+
if (scheduler !== null) {
|
|
383
|
+
this._requestFrame(scheduler);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
_onVisibilityChange = () => {
|
|
388
|
+
// Either direction is an interruption boundary: entering hidden means the
|
|
389
|
+
// browser stops delivering frames, so the NEXT delivered frame must not
|
|
390
|
+
// report a delta spanning the gap (G3). Clearing on the visible edge too
|
|
391
|
+
// is belt-and-braces for a missed hidden event — the worst case is one
|
|
392
|
+
// extra dt=0 frame.
|
|
393
|
+
this._lastTs = null;
|
|
394
|
+
this._updateSuspended();
|
|
395
|
+
};
|
|
396
|
+
_resolveScheduler() {
|
|
397
|
+
if (this._injectedScheduler !== null)
|
|
398
|
+
return this._injectedScheduler;
|
|
399
|
+
const g = globalThis;
|
|
400
|
+
// The availability check itself still runs on every call (§3.7: resolved
|
|
401
|
+
// at call time, not cached across an absence/presence flip).
|
|
402
|
+
if (typeof g.requestAnimationFrame !== "function" || typeof g.cancelAnimationFrame !== "function") {
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
if (this._globalScheduler === null) {
|
|
406
|
+
// `g` is just a typed alias for `globalThis` (not a snapshot), so these
|
|
407
|
+
// closures keep dereferencing the live global functions even though the
|
|
408
|
+
// wrapper object itself is created only once.
|
|
409
|
+
this._globalScheduler = {
|
|
410
|
+
request: (cb) => g.requestAnimationFrame(cb),
|
|
411
|
+
cancel: (handle) => g.cancelAnimationFrame(handle),
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
return this._globalScheduler;
|
|
415
|
+
}
|
|
416
|
+
// Arm the next frame (§3.4). The callback closes over the generation
|
|
417
|
+
// current at request time and re-checks it against the live `_gen` when the
|
|
418
|
+
// frame arrives; a callback that outlived its run bails here. See the
|
|
419
|
+
// `_gen` field comment for why this must be a per-request capture and not a
|
|
420
|
+
// live-field comparison.
|
|
421
|
+
_requestFrame(scheduler) {
|
|
422
|
+
const gen = this._gen;
|
|
423
|
+
this._handle = scheduler.request((timestamp) => {
|
|
424
|
+
if (gen !== this._gen)
|
|
425
|
+
return;
|
|
426
|
+
this._frame(timestamp);
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
_clearHandle() {
|
|
430
|
+
if (this._handle !== null) {
|
|
431
|
+
this._resolveScheduler()?.cancel(this._handle);
|
|
432
|
+
this._handle = null;
|
|
433
|
+
// Invalidate the cancelled callback's captured generation as well:
|
|
434
|
+
// cancel() is best-effort against a non-compliant scheduler, the
|
|
435
|
+
// generation is the guarantee (§3.4).
|
|
436
|
+
this._gen++;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
let registered = false;
|
|
442
|
+
function handleClick(event) {
|
|
443
|
+
const target = event.target;
|
|
444
|
+
if (!(target instanceof Element))
|
|
445
|
+
return;
|
|
446
|
+
const triggerElement = target.closest(`[${config.triggerAttribute}]`);
|
|
447
|
+
if (!triggerElement)
|
|
448
|
+
return;
|
|
449
|
+
const rafId = triggerElement.getAttribute(config.triggerAttribute);
|
|
450
|
+
if (!rafId)
|
|
451
|
+
return;
|
|
452
|
+
// Resolve the registered constructor at call time instead of importing Raf
|
|
453
|
+
// as a value. The value import created a components/Raf.ts ⇄ autoTrigger.ts
|
|
454
|
+
// cycle (Raf.connectedCallback() calls registerAutoTrigger()). instanceof
|
|
455
|
+
// against the customElements registry keeps the exact same identity guarantee
|
|
456
|
+
// — only the registered <wcs-raf> class matches — without the import cycle.
|
|
457
|
+
const RafCtor = customElements.get(config.tagNames.raf);
|
|
458
|
+
const rafElement = document.getElementById(rafId);
|
|
459
|
+
if (!RafCtor || !(rafElement instanceof RafCtor))
|
|
460
|
+
return;
|
|
461
|
+
// Suppress the element's default action so a loop can start without
|
|
462
|
+
// navigating. Intentional: do not attach data-raftarget to an element whose
|
|
463
|
+
// default action you also want (real <a href> link, form-submit button) — it
|
|
464
|
+
// will be cancelled. See README "Optional DOM Triggering".
|
|
465
|
+
event.preventDefault();
|
|
466
|
+
rafElement.start();
|
|
467
|
+
}
|
|
468
|
+
function registerAutoTrigger() {
|
|
469
|
+
if (registered)
|
|
470
|
+
return;
|
|
471
|
+
registered = true;
|
|
472
|
+
document.addEventListener("click", handleClick);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
class Raf extends HTMLElement {
|
|
476
|
+
static hasConnectedCallbackPromise = true;
|
|
477
|
+
static wcBindable = {
|
|
478
|
+
...RafCore.wcBindable,
|
|
479
|
+
properties: [
|
|
480
|
+
...RafCore.wcBindable.properties,
|
|
481
|
+
{ name: "trigger", event: "wcs-raf:trigger-changed" },
|
|
482
|
+
],
|
|
483
|
+
// Shell-level settable surface. `attribute` is a purely descriptive hint
|
|
484
|
+
// (per SPEC-extensions.md the binding core does not act on it) naming the
|
|
485
|
+
// mirrored HTML attribute, matching <wcs-timer>. `trigger` is a momentary
|
|
486
|
+
// command-property with no backing attribute, so it carries no hint.
|
|
487
|
+
// `start` / `stop` / `reset` / `pause` / `resume` commands are inherited
|
|
488
|
+
// from the Core above. Deliberately absent vs <wcs-timer>: `interval`
|
|
489
|
+
// (rAF has no period) and `immediate` (the first frame already IS the
|
|
490
|
+
// next rendering opportunity — no earlier meaningful moment exists).
|
|
491
|
+
inputs: [
|
|
492
|
+
{ name: "once", attribute: "once" },
|
|
493
|
+
{ name: "repeat", attribute: "repeat" },
|
|
494
|
+
{ name: "manual", attribute: "manual" },
|
|
495
|
+
{ name: "trigger" },
|
|
496
|
+
],
|
|
497
|
+
};
|
|
498
|
+
_core;
|
|
499
|
+
_trigger = false;
|
|
500
|
+
_connectedCallbackPromise = Promise.resolve();
|
|
501
|
+
_internals = null;
|
|
502
|
+
constructor() {
|
|
503
|
+
super();
|
|
504
|
+
this._core = new RafCore(this);
|
|
505
|
+
this._internals = this._initInternals();
|
|
506
|
+
this._wireStates({
|
|
507
|
+
"wcs-raf:running-changed": (d) => ({ running: d === true }),
|
|
508
|
+
"wcs-raf:suspended-changed": (d) => ({ suspended: d === true }),
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
// CSS state reflection (:state()) — debug-only snapshot getter. NOT part of
|
|
512
|
+
// wc-bindable (not a bind target); see README "CSS styling with :state()".
|
|
513
|
+
// MUST NOT return the live CustomStateSet (that would let callers write
|
|
514
|
+
// states from outside, defeating the point of :state() being read-only).
|
|
515
|
+
get debugStates() {
|
|
516
|
+
return this._internals ? [...this._internals.states] : [];
|
|
517
|
+
}
|
|
518
|
+
_initInternals() {
|
|
519
|
+
// never-throw (async-io-node-guidelines.md §3.6): attachInternals is absent
|
|
520
|
+
// in happy-dom / older environments, and pre-125 Chromium rejects
|
|
521
|
+
// non-dashed state names from states.add() (probed and discarded here).
|
|
522
|
+
// Either case silently disables reflection — the component still works,
|
|
523
|
+
// it just doesn't expose :state() selectors.
|
|
524
|
+
try {
|
|
525
|
+
if (typeof this.attachInternals !== "function")
|
|
526
|
+
return null;
|
|
527
|
+
const internals = this.attachInternals();
|
|
528
|
+
internals.states.add("wcs-probe");
|
|
529
|
+
internals.states.delete("wcs-probe");
|
|
530
|
+
return internals;
|
|
531
|
+
}
|
|
532
|
+
catch {
|
|
533
|
+
return null;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
_wireStates(map) {
|
|
537
|
+
if (this._internals === null)
|
|
538
|
+
return;
|
|
539
|
+
const states = this._internals.states;
|
|
540
|
+
for (const [event, toStates] of Object.entries(map)) {
|
|
541
|
+
this.addEventListener(event, (e) => {
|
|
542
|
+
const debug = this.hasAttribute("debug-states");
|
|
543
|
+
for (const [name, on] of Object.entries(toStates(e.detail))) {
|
|
544
|
+
try {
|
|
545
|
+
if (on) {
|
|
546
|
+
states.add(name);
|
|
547
|
+
}
|
|
548
|
+
else {
|
|
549
|
+
states.delete(name);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
catch { /* never-throw */ }
|
|
553
|
+
if (debug)
|
|
554
|
+
this.toggleAttribute(`data-wcs-state-${name}`, on);
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
// SSR (§4.1/§4.4): the Shell exposes the Core's readiness so a server-side
|
|
560
|
+
// renderer can await the connect-time probe before snapshotting. There is no
|
|
561
|
+
// async probe here (observe() resolves immediately), but the contract is
|
|
562
|
+
// uniform across IO nodes.
|
|
563
|
+
get connectedCallbackPromise() {
|
|
564
|
+
return this._connectedCallbackPromise;
|
|
565
|
+
}
|
|
566
|
+
// --- Attribute accessors ---
|
|
567
|
+
get once() {
|
|
568
|
+
return this.hasAttribute("once");
|
|
569
|
+
}
|
|
570
|
+
set once(value) {
|
|
571
|
+
if (value) {
|
|
572
|
+
this.setAttribute("once", "");
|
|
573
|
+
}
|
|
574
|
+
else {
|
|
575
|
+
this.removeAttribute("once");
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
get repeat() {
|
|
579
|
+
const attr = this.getAttribute("repeat");
|
|
580
|
+
if (attr === null || attr.trim() === "")
|
|
581
|
+
return 0;
|
|
582
|
+
// Strict parse via Number() ("3px" -> NaN, not 3), matching <wcs-timer>.
|
|
583
|
+
// Normalise any non-positive / non-numeric value to 0 (= unlimited).
|
|
584
|
+
const parsed = Number(attr);
|
|
585
|
+
return (Number.isFinite(parsed) && parsed > 0) ? parsed : 0;
|
|
586
|
+
}
|
|
587
|
+
set repeat(value) {
|
|
588
|
+
this.setAttribute("repeat", String(value));
|
|
589
|
+
}
|
|
590
|
+
get manual() {
|
|
591
|
+
return this.hasAttribute("manual");
|
|
592
|
+
}
|
|
593
|
+
set manual(value) {
|
|
594
|
+
if (value) {
|
|
595
|
+
this.setAttribute("manual", "");
|
|
596
|
+
}
|
|
597
|
+
else {
|
|
598
|
+
this.removeAttribute("manual");
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
// --- Core delegated getters ---
|
|
602
|
+
get tick() {
|
|
603
|
+
return this._core.tick;
|
|
604
|
+
}
|
|
605
|
+
get elapsed() {
|
|
606
|
+
return this._core.elapsed;
|
|
607
|
+
}
|
|
608
|
+
get dt() {
|
|
609
|
+
return this._core.dt;
|
|
610
|
+
}
|
|
611
|
+
get running() {
|
|
612
|
+
return this._core.running;
|
|
613
|
+
}
|
|
614
|
+
get suspended() {
|
|
615
|
+
return this._core.suspended;
|
|
616
|
+
}
|
|
617
|
+
// --- Command property ---
|
|
618
|
+
get trigger() {
|
|
619
|
+
return this._trigger;
|
|
620
|
+
}
|
|
621
|
+
set trigger(value) {
|
|
622
|
+
// Momentary command-property: a false→true write starts the loop. Mirrors
|
|
623
|
+
// <wcs-timer>. Prefer the command-token protocol (`command.start:
|
|
624
|
+
// $command.begin`) for state-driven starts; this exists mainly for the DOM
|
|
625
|
+
// click trigger and simple boolean bindings.
|
|
626
|
+
const v = !!value;
|
|
627
|
+
if (v) {
|
|
628
|
+
this._trigger = true;
|
|
629
|
+
this.start();
|
|
630
|
+
this._trigger = false;
|
|
631
|
+
// The `trigger-changed` event reports the momentary flag returning to
|
|
632
|
+
// false, i.e. that the trigger property *changed* — it is deliberately
|
|
633
|
+
// not gated on whether start() actually began a new run (same contract
|
|
634
|
+
// as <wcs-timer>).
|
|
635
|
+
this.dispatchEvent(new CustomEvent("wcs-raf:trigger-changed", {
|
|
636
|
+
detail: false,
|
|
637
|
+
bubbles: true,
|
|
638
|
+
}));
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
// --- Commands ---
|
|
642
|
+
start() {
|
|
643
|
+
// `once` is sugar for "fire exactly one frame": map it to repeat=1, but
|
|
644
|
+
// let an explicit repeat attribute win when both are present.
|
|
645
|
+
const repeat = this.repeat > 0 ? this.repeat : (this.once ? 1 : 0);
|
|
646
|
+
this._core.start({ repeat });
|
|
647
|
+
}
|
|
648
|
+
stop() {
|
|
649
|
+
this._core.stop();
|
|
650
|
+
}
|
|
651
|
+
reset() {
|
|
652
|
+
this._core.reset();
|
|
653
|
+
}
|
|
654
|
+
pause() {
|
|
655
|
+
this._core.pause();
|
|
656
|
+
}
|
|
657
|
+
resume() {
|
|
658
|
+
this._core.resume();
|
|
659
|
+
}
|
|
660
|
+
// --- Lifecycle ---
|
|
661
|
+
connectedCallback() {
|
|
662
|
+
this.style.display = "none";
|
|
663
|
+
if (config.autoTrigger) {
|
|
664
|
+
registerAutoTrigger();
|
|
665
|
+
}
|
|
666
|
+
// Establish monitoring (§3.5): observe() subscribes visibilitychange (the
|
|
667
|
+
// `suspended` output) and resolves once ready; expose it as
|
|
668
|
+
// connectedCallbackPromise for SSR. Note for SSR pages: an auto-started
|
|
669
|
+
// frame loop keeps scheduling — prefer `manual` in server-rendered markup
|
|
670
|
+
// (see README).
|
|
671
|
+
this._connectedCallbackPromise = this._core.observe();
|
|
672
|
+
if (!this.manual) {
|
|
673
|
+
this.start();
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
disconnectedCallback() {
|
|
677
|
+
// dispose() stops the loop, releases the visibility subscription and bumps
|
|
678
|
+
// the generation so a frame already queued cannot fire onto a disconnected
|
|
679
|
+
// element (§3.5 / §4.4).
|
|
680
|
+
this._core.dispose();
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function registerComponents() {
|
|
685
|
+
if (!customElements.get(config.tagNames.raf)) {
|
|
686
|
+
customElements.define(config.tagNames.raf, Raf);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function bootstrapRaf(userConfig) {
|
|
691
|
+
if (userConfig) {
|
|
692
|
+
setConfig(userConfig);
|
|
693
|
+
}
|
|
694
|
+
registerComponents();
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
export { RafCore, Raf as WcsRaf, bootstrapRaf, getConfig };
|
|
698
|
+
//# sourceMappingURL=index.esm.js.map
|