@brftech/filex-core 0.33.0 → 0.34.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brftech/filex-core",
3
- "version": "0.33.0",
3
+ "version": "0.34.1",
4
4
  "description": "filex core — Vue 3 source of truth for the filex file manager (FileExplorer + ConnectionsPanel SFCs, composables, types)",
5
5
  "type": "module",
6
6
  "main": "./dist/filex-core.umd.cjs",
@@ -8,8 +8,15 @@
8
8
 
9
9
  import { ref, type Ref } from 'vue';
10
10
  import { RealtimeClient, type PresenceUser, type PresenceMessage } from '../lib/realtime';
11
+ import { burstDebounce } from '../lib/burstDebounce';
11
12
 
12
13
  const RELOAD_DEBOUNCE_MS = 200;
14
+ // Ceiling on how long a run of change frames may postpone the reload it is
15
+ // waiting for — see burstDebounce for the starvation this exists to stop.
16
+ // Measured in a real browser on 2026-09-06, watching a folder while a
17
+ // 5 000-file zip was extracted into it: the first re-listing came 114 s into
18
+ // the job, and there was a 40 s gap in the middle.
19
+ const RELOAD_MAX_WAIT_MS = 2_000;
13
20
  const POLL_INTERVAL_MS = 12_000;
14
21
 
15
22
  export interface RealtimeApi {
@@ -28,19 +35,12 @@ export function useRealtime(api: RealtimeApi, opts: { reload: () => void }) {
28
35
 
29
36
  let client: RealtimeClient | null = null;
30
37
  let pendingSubscribe: string | null = null;
31
- let reloadTimer: ReturnType<typeof setTimeout> | null = null;
38
+ const debouncedReload = burstDebounce(() => opts.reload(), {
39
+ wait: RELOAD_DEBOUNCE_MS,
40
+ maxWait: RELOAD_MAX_WAIT_MS,
41
+ });
32
42
  let pollTimer: ReturnType<typeof setInterval> | null = null;
33
43
 
34
- function debouncedReload(): void {
35
- // A burst of change frames (e.g. a multi-file upload) collapses into one
36
- // soft reload of the current folder.
37
- if (reloadTimer) clearTimeout(reloadTimer);
38
- reloadTimer = setTimeout(() => {
39
- reloadTimer = null;
40
- opts.reload();
41
- }, RELOAD_DEBOUNCE_MS);
42
- }
43
-
44
44
  function onPresence(msg: PresenceMessage): void {
45
45
  // Ignore late frames for a folder we've already navigated away from.
46
46
  if (pendingSubscribe && msg.path && msg.path !== pendingSubscribe) return;
@@ -85,10 +85,7 @@ export function useRealtime(api: RealtimeApi, opts: { reload: () => void }) {
85
85
  }
86
86
 
87
87
  function stop(): void {
88
- if (reloadTimer) {
89
- clearTimeout(reloadTimer);
90
- reloadTimer = null;
91
- }
88
+ debouncedReload.cancel();
92
89
  if (pollTimer) {
93
90
  clearInterval(pollTimer);
94
91
  pollTimer = null;
@@ -0,0 +1,57 @@
1
+ // burstDebounce: a trailing debounce that cannot be starved.
2
+ //
3
+ // The explorer re-lists a folder when a change frame says it changed, debounced
4
+ // so a burst of frames costs one listing instead of N. A PLAIN trailing
5
+ // debounce has a failure mode that only shows up under a sustained stream:
6
+ // every frame clears the pending timer, so while frames keep arriving closer
7
+ // together than `wait`, the reload never happens at all. That is not "one
8
+ // reload instead of many" — it is zero, and the person watching the folder sees
9
+ // nothing change until the job that is writing the files finishes.
10
+ //
11
+ // Measured in a real browser on 2026-09-06 against a 5 000-file zip extraction
12
+ // (frames roughly every 40 ms for 195 s): the folder's first re-listing came
13
+ // 114 s in, with a 40 s gap after it.
14
+ //
15
+ // `maxWait` is the ceiling: however long the stream goes on, the call happens
16
+ // at most `maxWait` after the FIRST frame of the run, and the run then starts
17
+ // again. A quiet folder is untouched — one frame still calls `wait` later.
18
+ export interface BurstDebounceOptions {
19
+ /** Quiet period after the last call before firing. */
20
+ wait: number;
21
+ /** Ceiling from the first call of a run. Must be >= wait to mean anything. */
22
+ maxWait: number;
23
+ /** Clock, injectable for tests. */
24
+ now?: () => number;
25
+ }
26
+
27
+ export interface BurstDebounced {
28
+ (): void;
29
+ /** Drop any pending call (unmount). */
30
+ cancel(): void;
31
+ }
32
+
33
+ export function burstDebounce(fn: () => void, opts: BurstDebounceOptions): BurstDebounced {
34
+ const now = opts.now ?? (() => Date.now());
35
+ let timer: ReturnType<typeof setTimeout> | null = null;
36
+ let runStartedAt: number | null = null;
37
+
38
+ const call = () => {
39
+ const t = now();
40
+ if (runStartedAt === null) runStartedAt = t;
41
+ // Never later than the ceiling, never sooner than 0.
42
+ const untilCeiling = runStartedAt + opts.maxWait - t;
43
+ const delay = Math.max(0, Math.min(opts.wait, untilCeiling));
44
+ if (timer) clearTimeout(timer);
45
+ timer = setTimeout(() => {
46
+ timer = null;
47
+ runStartedAt = null;
48
+ fn();
49
+ }, delay);
50
+ };
51
+ call.cancel = () => {
52
+ if (timer) clearTimeout(timer);
53
+ timer = null;
54
+ runStartedAt = null;
55
+ };
56
+ return call;
57
+ }