@ryuzaki13/react-foundation-lib 2.14.1 → 2.16.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.
@@ -0,0 +1,8 @@
1
+ import { type DeadlineTimer, type DeadlineTimerOptions } from "./deadlineTimerTypes";
2
+ /**
3
+ * Ожидает один абсолютный срок. Длинное ожидание разбивается только на native
4
+ * timers: промежуточные части не вызывают предметный callback. Создание не
5
+ * запускает таймер, а отменённое поколение не оживает от позднего callback.
6
+ */
7
+ export declare function createDeadlineTimer({ onDue }: DeadlineTimerOptions): DeadlineTimer;
8
+ //# sourceMappingURL=createDeadlineTimer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"createDeadlineTimer.d.ts","sourceRoot":"","sources":["../../src/async-task-runner/createDeadlineTimer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,aAAa,EAAE,KAAK,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AASrF;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,EAAE,KAAK,EAAE,EAAE,oBAAoB,GAAG,aAAa,CAgDlF"}
@@ -0,0 +1,12 @@
1
+ /** Одноразовое пробуждение: предметную работу и обработку её отказа выполняет caller. */
2
+ export type DeadlineTimerOptions = Readonly<{
3
+ onDue: () => void;
4
+ }>;
5
+ /** Абсолютный срок в Unix milliseconds не зависит от длительности предыдущей задачи. */
6
+ export type DeadlineTimer = Readonly<{
7
+ /** Заменяет срок; null отменяет его. Некорректный срок не отменяет уже назначенный. */
8
+ schedule: (deadlineAtMs: number | null) => void;
9
+ /** Отзывает только ещё не начавшееся пробуждение, а не работу внутри onDue. */
10
+ cancel: () => void;
11
+ }>;
12
+ //# sourceMappingURL=deadlineTimerTypes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"deadlineTimerTypes.d.ts","sourceRoot":"","sources":["../../src/async-task-runner/deadlineTimerTypes.ts"],"names":[],"mappings":"AAAA,yFAAyF;AACzF,MAAM,MAAM,oBAAoB,GAAG,QAAQ,CAAC;IAC3C,KAAK,EAAE,MAAM,IAAI,CAAC;CAClB,CAAC,CAAC;AAEH,wFAAwF;AACxF,MAAM,MAAM,aAAa,GAAG,QAAQ,CAAC;IACpC,uFAAuF;IACvF,QAAQ,EAAE,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAC;IAChD,+EAA+E;IAC/E,MAAM,EAAE,MAAM,IAAI,CAAC;CACnB,CAAC,CAAC"}
@@ -1,3 +1,5 @@
1
1
  export { createCoalescingTaskRunner } from "./createCoalescingTaskRunner";
2
2
  export { type CoalescingTaskRunner, type CoalescingTaskRunnerOptions } from "./asyncTaskRunnerTypes";
3
+ export { createDeadlineTimer } from "./createDeadlineTimer";
4
+ export { type DeadlineTimer, type DeadlineTimerOptions } from "./deadlineTimerTypes";
3
5
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/async-task-runner/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,0BAA0B,EAAE,MAAM,8BAA8B,CAAC;AAC1E,OAAO,EAAE,KAAK,oBAAoB,EAAE,KAAK,2BAA2B,EAAE,MAAM,wBAAwB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/async-task-runner/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,0BAA0B,EAAE,MAAM,8BAA8B,CAAC;AAC1E,OAAO,EAAE,KAAK,oBAAoB,EAAE,KAAK,2BAA2B,EAAE,MAAM,wBAAwB,CAAC;AACrG,OAAO,EAAE,mBAAmB,EAAE,MAAM,uBAAuB,CAAC;AAC5D,OAAO,EAAE,KAAK,aAAa,EAAE,KAAK,oBAAoB,EAAE,MAAM,sBAAsB,CAAC"}
@@ -59,7 +59,41 @@ function e(e) {
59
59
  }
60
60
  };
61
61
  }
62
+ function t({ onDue: e }) {
63
+ let t = null, n = () => {
64
+ let e = t;
65
+ t = null, e && e.timer !== null && (clearTimeout(e.timer), e.timer = null);
66
+ }, r = (n) => {
67
+ let i = Math.max(1, Math.min(2147483647, n.deadlineAtMs - Date.now())), a = setTimeout(() => {
68
+ if (t === n && n.timer === a) {
69
+ if (n.timer = null, Date.now() < n.deadlineAtMs) {
70
+ r(n);
71
+ return;
72
+ }
73
+ t = null, e();
74
+ }
75
+ }, i);
76
+ n.timer = a, a.unref?.();
77
+ };
78
+ return {
79
+ schedule(e) {
80
+ if (e === null) {
81
+ n();
82
+ return;
83
+ }
84
+ if (!Number.isSafeInteger(e)) throw RangeError("Абсолютный срок должен быть безопасным целым числом Unix milliseconds.");
85
+ if (t?.deadlineAtMs === e) return;
86
+ n();
87
+ let i = {
88
+ deadlineAtMs: e,
89
+ timer: null
90
+ };
91
+ t = i, r(i);
92
+ },
93
+ cancel: n
94
+ };
95
+ }
62
96
  //#endregion
63
- export { e as createCoalescingTaskRunner };
97
+ export { e as createCoalescingTaskRunner, t as createDeadlineTimer };
64
98
 
65
99
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../../src/async-task-runner/createCoalescingTaskRunner.ts"],"sourcesContent":["import { type CoalescingTaskRunner, type CoalescingTaskRunnerOptions } from \"./asyncTaskRunnerTypes\";\n\ntype ScheduledWake = { kind: \"retry\" | \"tail\"; timer: ReturnType<typeof setTimeout> };\ntype RunnerGeneration = {\n\tpending: Promise<void> | null;\n\trerun: boolean;\n\tpollTimer: ReturnType<typeof setInterval> | null;\n\tscheduledWake: ScheduledWake | null;\n\treportingFailure: Readonly<{ error: unknown }> | null;\n};\n\n/**\n * Создаёт неактивный runner. Поколение защищает от поздних таймеров, single-flight\n * объединяет сигналы в один следующий проход, stop не имитирует отмену уже\n * выполняющейся атомарной задачи. Предметная запись и диагностика принадлежат caller.\n */\nexport function createCoalescingTaskRunner(options: CoalescingTaskRunnerOptions): CoalescingTaskRunner {\n\tfor (const delay of [options.retryDelayMs, ...(options.pollIntervalMs === undefined ? [] : [options.pollIntervalMs])]) {\n\t\t// Более длинную задержку Node превращает в 1 ms: молчаливое ограничение здесь опасно.\n\t\tif (!Number.isInteger(delay) || delay < 1 || delay > 2_147_483_647) {\n\t\t\tthrow new RangeError(\"Задержки runner должны быть целыми числами от 1 до 2147483647 ms.\");\n\t\t}\n\t}\n\tlet active: RunnerGeneration | null = null;\n\tlet stopping: Promise<void> | null = null;\n\tlet stopRevision = 0;\n\n\tconst clearScheduledWake = (generation: RunnerGeneration) => {\n\t\tif (generation.scheduledWake) clearTimeout(generation.scheduledWake.timer);\n\t\tgeneration.scheduledWake = null;\n\t};\n\tconst clearTimers = (generation: RunnerGeneration) => {\n\t\tclearScheduledWake(generation);\n\t\tif (generation.pollTimer !== null) clearInterval(generation.pollTimer);\n\t\tgeneration.pollTimer = null;\n\t};\n\tconst backgroundWake = (generation: RunnerGeneration) => {\n\t\tif (active !== generation || generation.reportingFailure || generation.scheduledWake?.kind === \"retry\") return;\n\t\t// Обычный отказ уже передан onError; отказ самого onError сохранён для\n\t\t// admission/stop. Timer не создаёт unhandled rejection и не теряет reporter error.\n\t\tvoid wake().catch(() => {});\n\t};\n\tconst schedule = (generation: RunnerGeneration, kind: ScheduledWake[\"kind\"], delay: number) => {\n\t\tif (active !== generation || generation.reportingFailure || generation.scheduledWake) return;\n\t\tconst timer = setTimeout(() => {\n\t\t\tif (active !== generation || generation.scheduledWake?.timer !== timer) return;\n\t\t\tgeneration.scheduledWake = null;\n\t\t\tbackgroundWake(generation);\n\t\t}, delay);\n\t\tgeneration.scheduledWake = { kind, timer };\n\t\ttimer.unref?.();\n\t};\n\tconst wake = (): Promise<void> => {\n\t\tconst generation = active;\n\t\tif (!generation) return Promise.resolve();\n\t\tif (generation.reportingFailure) return Promise.reject(generation.reportingFailure.error);\n\t\tif (generation.pending) {\n\t\t\tgeneration.rerun = true;\n\t\t\treturn generation.pending;\n\t\t}\n\t\t// Явный wake ускоряет retry. Только caller, не poll, может принять это решение.\n\t\tclearScheduledWake(generation);\n\t\tgeneration.rerun = false;\n\t\tgeneration.pending = Promise.resolve()\n\t\t\t.then(async () => {\n\t\t\t\t// stop до первой microtask не разрешает позднее выполнение callback.\n\t\t\t\tif (active === generation) await options.run();\n\t\t\t})\n\t\t\t.catch(async (error: unknown) => {\n\t\t\t\t// Уже начатый run сообщает отказ даже после stop; shutdown не скрывает ошибку.\n\t\t\t\ttry {\n\t\t\t\t\tawait options.onError(error);\n\t\t\t\t} catch (reporterError: unknown) {\n\t\t\t\t\t// Wrapper различает отсутствие отказа и реальные throw undefined/null.\n\t\t\t\t\tgeneration.reportingFailure = { error: reporterError };\n\t\t\t\t\tgeneration.rerun = false;\n\t\t\t\t\tclearTimers(generation);\n\t\t\t\t}\n\t\t\t\tschedule(generation, \"retry\", options.retryDelayMs);\n\t\t\t\tthrow error;\n\t\t\t})\n\t\t\t.finally(() => {\n\t\t\t\tgeneration.pending = null;\n\t\t\t\t// При отказе retry timer уже существует и не заменяется немедленным хвостом.\n\t\t\t\tif (generation.rerun) schedule(generation, \"tail\", 0);\n\t\t\t});\n\t\treturn generation.pending;\n\t};\n\tconst startGeneration = (requestedBeforeStop: number): Promise<void> => {\n\t\tif (requestedBeforeStop !== stopRevision) return Promise.reject(new Error(\"Запуск runner отменён более поздней остановкой.\"));\n\t\tif (active) {\n\t\t\tif (active.reportingFailure) return Promise.reject(active.reportingFailure.error);\n\t\t\t// Повторный start не является wake и не создаёт лишний хвост или обход retry.\n\t\t\treturn active.pending ?? Promise.resolve();\n\t\t}\n\t\tconst generation: RunnerGeneration = { pending: null, rerun: false, pollTimer: null, scheduledWake: null, reportingFailure: null };\n\t\tactive = generation;\n\t\tif (options.pollIntervalMs !== undefined) {\n\t\t\tgeneration.pollTimer = setInterval(() => backgroundWake(generation), options.pollIntervalMs);\n\t\t\tgeneration.pollTimer.unref?.();\n\t\t}\n\t\treturn wake();\n\t};\n\treturn {\n\t\tstart() {\n\t\t\tconst requestedBeforeStop = stopRevision;\n\t\t\treturn stopping ? stopping.then(() => startGeneration(requestedBeforeStop)) : startGeneration(requestedBeforeStop);\n\t\t},\n\t\twake,\n\t\tstop() {\n\t\t\tstopRevision++;\n\t\t\tif (stopping) return stopping;\n\t\t\tconst generation = active;\n\t\t\tif (!generation) return Promise.resolve();\n\t\t\tactive = null;\n\t\t\tgeneration.rerun = false;\n\t\t\tclearTimers(generation);\n\t\t\tstopping = (async () => {\n\t\t\t\t// Task failure уже обработан onError и остаётся у start/wake caller;\n\t\t\t\t// drain отдельно передаёт точный отказ reporter, если тот не сработал.\n\t\t\t\tawait generation.pending?.catch(() => {});\n\t\t\t\tif (generation.reportingFailure) throw generation.reportingFailure.error;\n\t\t\t})().finally(() => {\n\t\t\t\tstopping = null;\n\t\t\t});\n\t\t\treturn stopping;\n\t\t}\n\t};\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../src/async-task-runner/createCoalescingTaskRunner.ts","../../src/async-task-runner/createDeadlineTimer.ts"],"sourcesContent":["import { type CoalescingTaskRunner, type CoalescingTaskRunnerOptions } from \"./asyncTaskRunnerTypes\";\n\ntype ScheduledWake = { kind: \"retry\" | \"tail\"; timer: ReturnType<typeof setTimeout> };\ntype RunnerGeneration = {\n\tpending: Promise<void> | null;\n\trerun: boolean;\n\tpollTimer: ReturnType<typeof setInterval> | null;\n\tscheduledWake: ScheduledWake | null;\n\treportingFailure: Readonly<{ error: unknown }> | null;\n};\n\n/**\n * Создаёт неактивный runner. Поколение защищает от поздних таймеров, single-flight\n * объединяет сигналы в один следующий проход, stop не имитирует отмену уже\n * выполняющейся атомарной задачи. Предметная запись и диагностика принадлежат caller.\n */\nexport function createCoalescingTaskRunner(options: CoalescingTaskRunnerOptions): CoalescingTaskRunner {\n\tfor (const delay of [options.retryDelayMs, ...(options.pollIntervalMs === undefined ? [] : [options.pollIntervalMs])]) {\n\t\t// Более длинную задержку Node превращает в 1 ms: молчаливое ограничение здесь опасно.\n\t\tif (!Number.isInteger(delay) || delay < 1 || delay > 2_147_483_647) {\n\t\t\tthrow new RangeError(\"Задержки runner должны быть целыми числами от 1 до 2147483647 ms.\");\n\t\t}\n\t}\n\tlet active: RunnerGeneration | null = null;\n\tlet stopping: Promise<void> | null = null;\n\tlet stopRevision = 0;\n\n\tconst clearScheduledWake = (generation: RunnerGeneration) => {\n\t\tif (generation.scheduledWake) clearTimeout(generation.scheduledWake.timer);\n\t\tgeneration.scheduledWake = null;\n\t};\n\tconst clearTimers = (generation: RunnerGeneration) => {\n\t\tclearScheduledWake(generation);\n\t\tif (generation.pollTimer !== null) clearInterval(generation.pollTimer);\n\t\tgeneration.pollTimer = null;\n\t};\n\tconst backgroundWake = (generation: RunnerGeneration) => {\n\t\tif (active !== generation || generation.reportingFailure || generation.scheduledWake?.kind === \"retry\") return;\n\t\t// Обычный отказ уже передан onError; отказ самого onError сохранён для\n\t\t// admission/stop. Timer не создаёт unhandled rejection и не теряет reporter error.\n\t\tvoid wake().catch(() => {});\n\t};\n\tconst schedule = (generation: RunnerGeneration, kind: ScheduledWake[\"kind\"], delay: number) => {\n\t\tif (active !== generation || generation.reportingFailure || generation.scheduledWake) return;\n\t\tconst timer = setTimeout(() => {\n\t\t\tif (active !== generation || generation.scheduledWake?.timer !== timer) return;\n\t\t\tgeneration.scheduledWake = null;\n\t\t\tbackgroundWake(generation);\n\t\t}, delay);\n\t\tgeneration.scheduledWake = { kind, timer };\n\t\ttimer.unref?.();\n\t};\n\tconst wake = (): Promise<void> => {\n\t\tconst generation = active;\n\t\tif (!generation) return Promise.resolve();\n\t\tif (generation.reportingFailure) return Promise.reject(generation.reportingFailure.error);\n\t\tif (generation.pending) {\n\t\t\tgeneration.rerun = true;\n\t\t\treturn generation.pending;\n\t\t}\n\t\t// Явный wake ускоряет retry. Только caller, не poll, может принять это решение.\n\t\tclearScheduledWake(generation);\n\t\tgeneration.rerun = false;\n\t\tgeneration.pending = Promise.resolve()\n\t\t\t.then(async () => {\n\t\t\t\t// stop до первой microtask не разрешает позднее выполнение callback.\n\t\t\t\tif (active === generation) await options.run();\n\t\t\t})\n\t\t\t.catch(async (error: unknown) => {\n\t\t\t\t// Уже начатый run сообщает отказ даже после stop; shutdown не скрывает ошибку.\n\t\t\t\ttry {\n\t\t\t\t\tawait options.onError(error);\n\t\t\t\t} catch (reporterError: unknown) {\n\t\t\t\t\t// Wrapper различает отсутствие отказа и реальные throw undefined/null.\n\t\t\t\t\tgeneration.reportingFailure = { error: reporterError };\n\t\t\t\t\tgeneration.rerun = false;\n\t\t\t\t\tclearTimers(generation);\n\t\t\t\t}\n\t\t\t\tschedule(generation, \"retry\", options.retryDelayMs);\n\t\t\t\tthrow error;\n\t\t\t})\n\t\t\t.finally(() => {\n\t\t\t\tgeneration.pending = null;\n\t\t\t\t// При отказе retry timer уже существует и не заменяется немедленным хвостом.\n\t\t\t\tif (generation.rerun) schedule(generation, \"tail\", 0);\n\t\t\t});\n\t\treturn generation.pending;\n\t};\n\tconst startGeneration = (requestedBeforeStop: number): Promise<void> => {\n\t\tif (requestedBeforeStop !== stopRevision) return Promise.reject(new Error(\"Запуск runner отменён более поздней остановкой.\"));\n\t\tif (active) {\n\t\t\tif (active.reportingFailure) return Promise.reject(active.reportingFailure.error);\n\t\t\t// Повторный start не является wake и не создаёт лишний хвост или обход retry.\n\t\t\treturn active.pending ?? Promise.resolve();\n\t\t}\n\t\tconst generation: RunnerGeneration = { pending: null, rerun: false, pollTimer: null, scheduledWake: null, reportingFailure: null };\n\t\tactive = generation;\n\t\tif (options.pollIntervalMs !== undefined) {\n\t\t\tgeneration.pollTimer = setInterval(() => backgroundWake(generation), options.pollIntervalMs);\n\t\t\tgeneration.pollTimer.unref?.();\n\t\t}\n\t\treturn wake();\n\t};\n\treturn {\n\t\tstart() {\n\t\t\tconst requestedBeforeStop = stopRevision;\n\t\t\treturn stopping ? stopping.then(() => startGeneration(requestedBeforeStop)) : startGeneration(requestedBeforeStop);\n\t\t},\n\t\twake,\n\t\tstop() {\n\t\t\tstopRevision++;\n\t\t\tif (stopping) return stopping;\n\t\t\tconst generation = active;\n\t\t\tif (!generation) return Promise.resolve();\n\t\t\tactive = null;\n\t\t\tgeneration.rerun = false;\n\t\t\tclearTimers(generation);\n\t\t\tstopping = (async () => {\n\t\t\t\t// Task failure уже обработан onError и остаётся у start/wake caller;\n\t\t\t\t// drain отдельно передаёт точный отказ reporter, если тот не сработал.\n\t\t\t\tawait generation.pending?.catch(() => {});\n\t\t\t\tif (generation.reportingFailure) throw generation.reportingFailure.error;\n\t\t\t})().finally(() => {\n\t\t\t\tstopping = null;\n\t\t\t});\n\t\t\treturn stopping;\n\t\t}\n\t};\n}\n","import { type DeadlineTimer, type DeadlineTimerOptions } from \"./deadlineTimerTypes\";\n\nconst MAX_NATIVE_DELAY_MS = 2_147_483_647;\n\ntype DeadlineGeneration = {\n\tdeadlineAtMs: number;\n\ttimer: ReturnType<typeof setTimeout> | null;\n};\n\n/**\n * Ожидает один абсолютный срок. Длинное ожидание разбивается только на native\n * timers: промежуточные части не вызывают предметный callback. Создание не\n * запускает таймер, а отменённое поколение не оживает от позднего callback.\n */\nexport function createDeadlineTimer({ onDue }: DeadlineTimerOptions): DeadlineTimer {\n\tlet active: DeadlineGeneration | null = null;\n\n\tconst cancel = (): void => {\n\t\tconst generation = active;\n\t\tactive = null;\n\t\tif (generation && generation.timer !== null) {\n\t\t\tclearTimeout(generation.timer);\n\t\t\tgeneration.timer = null;\n\t\t}\n\t};\n\tconst arm = (generation: DeadlineGeneration): void => {\n\t\t// Node превращает переполнение delay в 1 ms. Даже годовой deadline нельзя\n\t\t// передавать напрямую; оставшийся срок перепроверяется после каждой части.\n\t\tconst delay = Math.max(1, Math.min(MAX_NATIVE_DELAY_MS, generation.deadlineAtMs - Date.now()));\n\t\tconst timer = setTimeout(() => {\n\t\t\tif (active !== generation || generation.timer !== timer) return;\n\t\t\tgeneration.timer = null;\n\t\t\tif (Date.now() < generation.deadlineAtMs) {\n\t\t\t\tarm(generation);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\t// Срок отзывается до callback: onDue может безопасно назначить следующий.\n\t\t\t// Ошибки и async rejection принадлежат caller, а не timer primitive.\n\t\t\tactive = null;\n\t\t\tonDue();\n\t\t}, delay);\n\t\tgeneration.timer = timer;\n\t\ttimer.unref?.();\n\t};\n\n\treturn {\n\t\tschedule(deadlineAtMs) {\n\t\t\tif (deadlineAtMs === null) {\n\t\t\t\tcancel();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (!Number.isSafeInteger(deadlineAtMs)) {\n\t\t\t\tthrow new RangeError(\"Абсолютный срок должен быть безопасным целым числом Unix milliseconds.\");\n\t\t\t}\n\t\t\tif (active?.deadlineAtMs === deadlineAtMs) return;\n\t\t\tcancel();\n\t\t\tconst generation: DeadlineGeneration = { deadlineAtMs, timer: null };\n\t\t\tactive = generation;\n\t\t\tarm(generation);\n\t\t},\n\t\tcancel\n\t};\n}\n"],"mappings":"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"}
@@ -1,2 +1,3 @@
1
- export * from "./useFetchNextPageEffect";
1
+ export { useFetchNextPageEffect } from "./useFetchNextPageEffect";
2
+ export { type UseFetchNextPageEffectOptions } from "./useFetchNextPageEffectTypes";
2
3
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/virtualizer/index.ts"],"names":[],"mappings":"AAAA,cAAc,0BAA0B,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/virtualizer/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,sBAAsB,EAAE,MAAM,0BAA0B,CAAC;AAClE,OAAO,EAAE,KAAK,6BAA6B,EAAE,MAAM,+BAA+B,CAAC"}
@@ -1,16 +1,28 @@
1
1
  import { useEffect as e, useRef as t } from "react";
2
2
  //#region src/virtualizer/useFetchNextPageEffect.ts
3
- function n({ virtualItems: n, currentItemsCount: r, fetchNextPage: i, hasNextPage: a }) {
4
- let o = t(!1);
5
- e(() => {
6
- !n.length || !r || n[n.length - 1].index >= r - 1 && a && !o.current && (o.current = !0, i?.().finally(() => {
7
- o.current = !1;
3
+ function n({ virtualItems: n, currentItemsCount: r, fetchNextPage: i, hasNextPage: a, onError: o }) {
4
+ let s = t(!1), c = t(null), l = t(!1);
5
+ e(() => (l.current = !0, () => {
6
+ l.current = !1;
7
+ }), []), e(() => {
8
+ (!a || c.current !== r) && (c.current = null), !(!n.length || !r || !a || s.current || c.current === r) && n[n.length - 1].index >= r - 1 && (s.current = !0, Promise.resolve().then(i).then(() => {
9
+ s.current = !1;
10
+ }, async (e) => {
11
+ c.current = r;
12
+ try {
13
+ l.current && o ? await o(e) : console.error("Не удалось загрузить следующую страницу виртуализированного списка.", e);
14
+ } catch (t) {
15
+ console.error("Не удалось обработать ошибку дозагрузки виртуализированного списка.", e, t);
16
+ } finally {
17
+ s.current = !1;
18
+ }
8
19
  }));
9
20
  }, [
10
21
  n,
11
22
  r,
12
23
  a,
13
- i
24
+ i,
25
+ o
14
26
  ]);
15
27
  }
16
28
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../../src/virtualizer/useFetchNextPageEffect.ts"],"sourcesContent":["import { useEffect, useRef } from \"react\";\n\nimport { VirtualItem } from \"@tanstack/react-virtual\";\n\nexport function useFetchNextPageEffect({\n\tvirtualItems,\n\tcurrentItemsCount,\n\tfetchNextPage,\n\thasNextPage\n}: {\n\tvirtualItems: VirtualItem[];\n\tcurrentItemsCount: number;\n\thasNextPage: boolean;\n\tfetchNextPage: () => Promise<unknown>;\n}) {\n\tconst fetchingRef = useRef(false);\n\n\tuseEffect(() => {\n\t\tif (!virtualItems.length || !currentItemsCount) return;\n\t\tconst lastVirtual = virtualItems[virtualItems.length - 1];\n\n\t\tif (lastVirtual.index >= currentItemsCount - 1 && hasNextPage && !fetchingRef.current) {\n\t\t\tfetchingRef.current = true;\n\t\t\tfetchNextPage?.().finally(() => {\n\t\t\t\tfetchingRef.current = false;\n\t\t\t});\n\t\t}\n\t}, [virtualItems, currentItemsCount, hasNextPage, fetchNextPage]);\n}\n"],"mappings":";;AAIA,SAAgB,EAAuB,EACtC,iBACA,sBACA,kBACA,kBAME;CACF,IAAM,IAAc,EAAO,EAAK;CAEhC,QAAgB;EACX,CAAC,EAAa,UAAU,CAAC,KACT,EAAa,EAAa,SAAS,EAEnD,CAAY,SAAS,IAAoB,KAAK,KAAe,CAAC,EAAY,YAC7E,EAAY,UAAU,IACtB,IAAgB,CAAC,CAAC,cAAc;GAC/B,EAAY,UAAU;EACvB,CAAC;CAEH,GAAG;EAAC;EAAc;EAAmB;EAAa;CAAa,CAAC;AACjE"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../src/virtualizer/useFetchNextPageEffect.ts"],"sourcesContent":["import { useEffect, useRef } from \"react\";\n\nimport { type UseFetchNextPageEffectOptions } from \"./useFetchNextPageEffectTypes\";\n\n/**\n * Достигнутый край запускает одну дозагрузку. Отказ не превращается в detached\n * rejection и не повторяется из-за новых virtual items при каждом scroll/render.\n */\nexport function useFetchNextPageEffect({\n\tvirtualItems,\n\tcurrentItemsCount,\n\tfetchNextPage,\n\thasNextPage,\n\tonError\n}: UseFetchNextPageEffectOptions): void {\n\tconst fetchingRef = useRef(false);\n\tconst failedItemsCountRef = useRef<number | null>(null);\n\tconst mountedRef = useRef(false);\n\n\tuseEffect(() => {\n\t\tmountedRef.current = true;\n\t\treturn () => {\n\t\t\t// Transport принадлежит caller и не отменяется; поздний отказ остаётся\n\t\t\t// диагностикой, но уже не вызывает callback размонтированного UI.\n\t\t\tmountedRef.current = false;\n\t\t};\n\t}, []);\n\n\tuseEffect(() => {\n\t\t// Новые данные либо явное выключение pagination разрешают следующую\n\t\t// попытку. Identity callback и видимого диапазона не являются retry.\n\t\tif (!hasNextPage || failedItemsCountRef.current !== currentItemsCount) failedItemsCountRef.current = null;\n\t\tif (\n\t\t\t!virtualItems.length ||\n\t\t\t!currentItemsCount ||\n\t\t\t!hasNextPage ||\n\t\t\tfetchingRef.current ||\n\t\t\tfailedItemsCountRef.current === currentItemsCount\n\t\t)\n\t\t\treturn;\n\t\tconst lastVirtual = virtualItems[virtualItems.length - 1];\n\n\t\tif (lastVirtual.index >= currentItemsCount - 1) {\n\t\t\tfetchingRef.current = true;\n\t\t\t// Начало через Promise охватывает и синхронный throw transport callback.\n\t\t\t// Обе ветки завершения обработаны: finally не создаёт второй rejected Promise.\n\t\t\tvoid Promise.resolve()\n\t\t\t\t.then(fetchNextPage)\n\t\t\t\t.then(\n\t\t\t\t\t() => {\n\t\t\t\t\t\tfetchingRef.current = false;\n\t\t\t\t\t},\n\t\t\t\t\tasync (error: unknown) => {\n\t\t\t\t\t\tfailedItemsCountRef.current = currentItemsCount;\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tif (mountedRef.current && onError) await onError(error);\n\t\t\t\t\t\t\telse console.error(\"Не удалось загрузить следующую страницу виртуализированного списка.\", error);\n\t\t\t\t\t\t} catch (reporterError: unknown) {\n\t\t\t\t\t\t\tconsole.error(\"Не удалось обработать ошибку дозагрузки виртуализированного списка.\", error, reporterError);\n\t\t\t\t\t\t} finally {\n\t\t\t\t\t\t\tfetchingRef.current = false;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t);\n\t\t}\n\t}, [virtualItems, currentItemsCount, hasNextPage, fetchNextPage, onError]);\n}\n"],"mappings":";;AAQA,SAAgB,EAAuB,EACtC,iBACA,sBACA,kBACA,gBACA,cACuC;CACvC,IAAM,IAAc,EAAO,EAAK,GAC1B,IAAsB,EAAsB,IAAI,GAChD,IAAa,EAAO,EAAK;CAW/B,AATA,SACC,EAAW,UAAU,UACR;EAGZ,EAAW,UAAU;CACtB,IACE,CAAC,CAAC,GAEL,QAAgB;EAGf,CAAI,CAAC,KAAe,EAAoB,YAAY,OAAmB,EAAoB,UAAU,OAEpG,GAAC,EAAa,UACd,CAAC,KACD,CAAC,KACD,EAAY,WACZ,EAAoB,YAAY,MAGb,EAAa,EAAa,SAAS,EAEnD,CAAY,SAAS,IAAoB,MAC5C,EAAY,UAAU,IAGtB,QAAa,QAAQ,CAAC,CACpB,KAAK,CAAa,CAAC,CACnB,WACM;GACL,EAAY,UAAU;EACvB,GACA,OAAO,MAAmB;GACzB,EAAoB,UAAU;GAC9B,IAAI;IACH,AAAI,EAAW,WAAW,IAAS,MAAM,EAAQ,CAAK,IACjD,QAAQ,MAAM,uEAAuE,CAAK;GAChG,SAAS,GAAwB;IAChC,QAAQ,MAAM,uEAAuE,GAAO,CAAa;GAC1G,UAAU;IACT,EAAY,UAAU;GACvB;EACD,CACD;CAEH,GAAG;EAAC;EAAc;EAAmB;EAAa;EAAe;CAAO,CAAC;AAC1E"}
@@ -1,8 +1,7 @@
1
- import { VirtualItem } from "@tanstack/react-virtual";
2
- export declare function useFetchNextPageEffect({ virtualItems, currentItemsCount, fetchNextPage, hasNextPage }: {
3
- virtualItems: VirtualItem[];
4
- currentItemsCount: number;
5
- hasNextPage: boolean;
6
- fetchNextPage: () => Promise<unknown>;
7
- }): void;
1
+ import { type UseFetchNextPageEffectOptions } from "./useFetchNextPageEffectTypes";
2
+ /**
3
+ * Достигнутый край запускает одну дозагрузку. Отказ не превращается в detached
4
+ * rejection и не повторяется из-за новых virtual items при каждом scroll/render.
5
+ */
6
+ export declare function useFetchNextPageEffect({ virtualItems, currentItemsCount, fetchNextPage, hasNextPage, onError }: UseFetchNextPageEffectOptions): void;
8
7
  //# sourceMappingURL=useFetchNextPageEffect.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"useFetchNextPageEffect.d.ts","sourceRoot":"","sources":["../../src/virtualizer/useFetchNextPageEffect.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAEtD,wBAAgB,sBAAsB,CAAC,EACtC,YAAY,EACZ,iBAAiB,EACjB,aAAa,EACb,WAAW,EACX,EAAE;IACF,YAAY,EAAE,WAAW,EAAE,CAAC;IAC5B,iBAAiB,EAAE,MAAM,CAAC;IAC1B,WAAW,EAAE,OAAO,CAAC;IACrB,aAAa,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;CACtC,QAcA"}
1
+ {"version":3,"file":"useFetchNextPageEffect.d.ts","sourceRoot":"","sources":["../../src/virtualizer/useFetchNextPageEffect.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,6BAA6B,EAAE,MAAM,+BAA+B,CAAC;AAEnF;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,EACtC,YAAY,EACZ,iBAAiB,EACjB,aAAa,EACb,WAAW,EACX,OAAO,EACP,EAAE,6BAA6B,GAAG,IAAI,CAoDtC"}
@@ -0,0 +1,11 @@
1
+ import { type VirtualItem } from "@tanstack/react-virtual";
2
+ /** Дозагрузка не владеет данными и retry: caller передаёт состояние своей пагинации. */
3
+ export type UseFetchNextPageEffectOptions = Readonly<{
4
+ virtualItems: readonly VirtualItem[];
5
+ currentItemsCount: number;
6
+ hasNextPage: boolean;
7
+ fetchNextPage: () => Promise<unknown>;
8
+ /** Ошибка передаётся один раз; без callback остаётся явная console-диагностика. */
9
+ onError?: (error: unknown) => void | Promise<void>;
10
+ }>;
11
+ //# sourceMappingURL=useFetchNextPageEffectTypes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useFetchNextPageEffectTypes.d.ts","sourceRoot":"","sources":["../../src/virtualizer/useFetchNextPageEffectTypes.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAE3D,wFAAwF;AACxF,MAAM,MAAM,6BAA6B,GAAG,QAAQ,CAAC;IACpD,YAAY,EAAE,SAAS,WAAW,EAAE,CAAC;IACrC,iBAAiB,EAAE,MAAM,CAAC;IAC1B,WAAW,EAAE,OAAO,CAAC;IACrB,aAAa,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;IACtC,mFAAmF;IACnF,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACnD,CAAC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ryuzaki13/react-foundation-lib",
3
- "version": "2.14.1",
3
+ "version": "2.16.0",
4
4
  "description": "Reusable React and TypeScript foundation utilities, hooks, and primitives.",
5
5
  "license": "MIT",
6
6
  "author": "Ryuzaki13",
@@ -1,10 +1,14 @@
1
- # Последовательный async runner
1
+ # Последовательный async runner и абсолютный deadline
2
2
 
3
3
  Публичный импорт: `@ryuzaki13/react-foundation-lib/async-task-runner`.
4
4
  Runner объединяет повторные сигналы, повторяет отказавшую задачу и корректно
5
5
  завершает lifecycle. Он не знает о HTTP, БД, React или предметном составе.
6
6
  Создание и импорт не вызывают `run` и не создают таймеры.
7
7
 
8
+ `createDeadlineTimer` дополняет runner независимым одноразовым пробуждением в
9
+ абсолютный момент. Он не задаёт polling или retry policy и не вызывает
10
+ предметную работу при промежуточных частях длинного ожидания.
11
+
8
12
  ```ts
9
13
  import { createCoalescingTaskRunner } from "@ryuzaki13/react-foundation-lib/async-task-runner";
10
14
 
@@ -77,3 +81,51 @@ stop отменяет такой отложенный start, не позволя
77
81
  создаёт цикл самодренирования. Остановка принадлежит внешнему lifecycle.
78
82
  Callback, который никогда не завершается, удерживает drain; timeout и реальная
79
83
  отмена внешней операции остаются ответственностью приложения.
84
+
85
+ ## Абсолютный одноразовый срок
86
+
87
+ ```ts
88
+ import { createDeadlineTimer } from "@ryuzaki13/react-foundation-lib/async-task-runner";
89
+
90
+ const deadline = createDeadlineTimer({
91
+ onDue: () => {
92
+ // Async callback имеет собственную обработку отказа у владельца работы.
93
+ void runner.wake().catch(reportTaskFailure);
94
+ }
95
+ });
96
+ deadline.schedule(nextEffectiveAtMs);
97
+ // При остановке сначала отменяется допуск timer, затем ожидается работа runner.
98
+ deadline.cancel();
99
+ await runner.stop();
100
+ ```
101
+
102
+ Создание timer не запускает callback и не создаёт native timer.
103
+ `schedule(deadlineAtMs: number | null): void` заменяет прежний срок, а `null`
104
+ отменяет его. Срок — безопасное целое число абсолютных Unix milliseconds;
105
+ `NaN`, бесконечность, дробное или небезопасное число дают `RangeError` до
106
+ побочных эффектов и не отменяют уже назначенный корректный срок. Повтор того
107
+ же pending срока идемпотентен. Просроченный срок, включая отрицательное число,
108
+ выполняется отдельной timer-задачей, а не синхронно внутри `schedule`.
109
+
110
+ Задержка каждого native timer не превышает 2147483647 ms. Если абсолютный срок
111
+ дальше, ожидание разбивается на части с повторной проверкой `Date.now()`.
112
+ Промежуточные части не вызывают `onDue`, не читают внешние источники и не
113
+ выполняют фоновые предметные проходы. Одновременно существует не более одного
114
+ native timer. В Node используется `unref`: само ожидание не удерживает процесс.
115
+
116
+ `onDue` вызывается один раз за назначенный срок. До вызова срок снимается, поэтому
117
+ callback может назначить следующий срок. `cancel(): void` удаляет pending timer
118
+ и отзывает его поколение; поздние callbacks отменённого или заменённого срока
119
+ игнорируются. Cancel не отменяет уже начавшуюся работу внутри `onDue`. Новая
120
+ `schedule` после cancel разрешена; окончательный lifecycle принадлежит caller.
121
+
122
+ Timer не знает timezone, календарных правил, БД, SQL, transport или React.
123
+ Вычисление абсолютного срока, обработка синхронных ошибок и async rejection
124
+ в callback, retry и остановка атомарной работы остаются у caller. Он не ожидает
125
+ Promise, возвращённый callback, и не добавляет собственной диагностики.
126
+
127
+ Точность зависит от event loop и системных часов. После перевода часов назад
128
+ ранний callback перевзводит оставшееся ожидание; после перевода часов вперёд
129
+ просроченность обнаруживается при ближайшем уже назначенном timer callback,
130
+ не через скрытый interval. Этот primitive не гарантирует немедленную реакцию
131
+ на изменение часов и не заменяет внешний startup/reconnect catch-up.
@@ -36,16 +36,18 @@ useFetchNextPageEffect({
36
36
  virtualItems,
37
37
  currentItemsCount,
38
38
  fetchNextPage,
39
- hasNextPage
39
+ hasNextPage,
40
+ onError
40
41
  });
41
42
  ```
42
43
 
43
- | Поле | Значение |
44
- | ------------------- | ------------------------------------ |
45
- | `virtualItems` | Текущий массив `VirtualItem[]` |
46
- | `currentItemsCount` | Число реально загруженных data items |
47
- | `hasNextPage` | Есть ли следующая server page |
48
- | `fetchNextPage` | Stable функция, возвращающая Promise |
44
+ | Поле | Значение |
45
+ | ------------------- | ------------------------------------------------------------------- |
46
+ | `virtualItems` | Текущий массив `readonly VirtualItem[]` |
47
+ | `currentItemsCount` | Число реально загруженных data items |
48
+ | `hasNextPage` | Есть ли следующая server page |
49
+ | `fetchNextPage` | Stable функция, возвращающая Promise |
50
+ | `onError` | Необязательный callback `(error: unknown) => void \| Promise<void>` |
49
51
 
50
52
  Hook ничего не возвращает.
51
53
 
@@ -81,25 +83,41 @@ Trigger происходит строго на последнем загруже
81
83
 
82
84
  ## Защита от параллельного fetch
83
85
 
84
- Внутренний `useRef(false)` устанавливается до вызова и сбрасывается в `.finally()`:
86
+ Внутренний single-flight ref устанавливается до вызова и сбрасывается после
87
+ успеха либо завершения обработки ошибки:
85
88
 
86
89
  ```text
87
90
  idle ── trigger ──► fetchingRef=true
88
91
  │
89
92
  Promise pending
90
93
  │
91
- resolve или reject
94
+ resolve или завершение onError
92
95
  ▼
93
96
  fetchingRef=false
94
97
  ```
95
98
 
96
99
  Защита действует только внутри одного mounted hook instance. TanStack Query также должен владеть дедупликацией/network state.
97
100
 
98
- Если `fetchNextPage` отклоняет Promise, hook не добавляет `.catch`; rejection может стать unhandled. Передавайте функцию с принятой error policy или wrapper, который корректно обрабатывает ожидаемую ошибку.
101
+ ## Ошибки и повторная попытка
102
+
103
+ Promise rejection и синхронный throw `fetchNextPage` передаются в `onError`
104
+ один раз. Hook ожидает async callback; его собственный отказ также обрабатывается.
105
+ Без `onError`, при отказе самого callback либо после unmount исходная ошибка
106
+ остаётся в явной `console.error`-диагностике с контекстом дозагрузки. Hook не
107
+ создаёт дополнительный unhandled Promise rejection и не решает, как показывать
108
+ ошибку пользователю: это остаётся у владельца Query и UI.
109
+
110
+ После отказа hook не пытается загрузить ту же страницу при каждом scroll/render.
111
+ Неудачная попытка привязана к `currentItemsCount`: новое количество загруженных
112
+ строк либо явное выключение `hasNextPage` и последующее включение разрешают новую
113
+ попытку. Смена identity `fetchNextPage`, `onError` или `virtualItems` не является
114
+ retry. Изменение данных без изменения количества само по себе latch не снимает;
115
+ явная ручная retry-команда остаётся у владельца Query. Интервалов и собственной
116
+ retry-policy в hook нет.
99
117
 
100
118
  ## Referential stability
101
119
 
102
- Effect зависит от `virtualItems`, `currentItemsCount`, `hasNextPage`, `fetchNextPage`.
120
+ Effect зависит от `virtualItems`, `currentItemsCount`, `hasNextPage`, `fetchNextPage`, `onError`.
103
121
 
104
122
  - Используйте значение virtualizer как предусмотрено его API.
105
123
  - Не создавайте лишний async callback с новой identity без причины.
@@ -110,7 +128,9 @@ Effect зависит от `virtualItems`, `currentItemsCount`, `hasNextPage`, `
110
128
 
111
129
  Сам hook не читает `window`, но является React effect и не выполняет fetch во время server render. В browser effect запускается после commit.
112
130
 
113
- При unmount Promise не отменяется. Abort/cancel должен быть реализован query/transport owner. `fetchingRef` больше не влияет после удаления hook instance.
131
+ При unmount Promise не отменяется. Abort/cancel должен быть реализован query/transport owner.
132
+ Поздний отказ не вызывает `onError` размонтированного UI, но остаётся в явной
133
+ console-диагностике. Уже начатый async `onError` не отменяется, его отказ не теряется.
114
134
 
115
135
  ## Типичные ошибки
116
136
 
@@ -118,17 +138,20 @@ Effect зависит от `virtualItems`, `currentItemsCount`, `hasNextPage`, `
118
138
  - Включить client-side sorting/filtering одной страницы при server-side модели.
119
139
  - Добавить фиктивный loader row в `currentItemsCount`.
120
140
  - Ожидать preload заранее — trigger находится на самом конце.
121
- - Передать rejecting Promise без error handling.
141
+ - Ожидать, что hook сам покажет Query-ошибку или автоматически повторит failed page.
122
142
  - Выполнять fetch прямо в row render вместо effect.
123
143
  - Использовать изменяемый index как domain identity строки.
124
144
 
125
145
  ## API
126
146
 
127
147
  ```ts
128
- function useFetchNextPageEffect(args: {
129
- virtualItems: VirtualItem[];
148
+ type UseFetchNextPageEffectOptions = Readonly<{
149
+ virtualItems: readonly VirtualItem[];
130
150
  currentItemsCount: number;
131
151
  hasNextPage: boolean;
132
152
  fetchNextPage: () => Promise<unknown>;
133
- }): void;
153
+ onError?: (error: unknown) => void | Promise<void>;
154
+ }>;
155
+
156
+ function useFetchNextPageEffect(options: UseFetchNextPageEffectOptions): void;
134
157
  ```