react-sync-ui 1.0.3 → 2.0.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 +111 -0
- package/README.md +336 -193
- package/dist/index.d.ts +4 -14
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +195 -7
- package/dist/index.js.map +1 -0
- package/dist/syncUI.d.ts +19 -19
- package/dist/syncUI.d.ts.map +1 -0
- package/package.json +87 -39
- package/src/index.ts +11 -2
- package/src/syncUI.tsx +402 -136
- package/dist/react-sync-ui.cjs.development.js +0 -127
- package/dist/react-sync-ui.cjs.development.js.map +0 -1
- package/dist/react-sync-ui.cjs.production.min.js +0 -2
- package/dist/react-sync-ui.cjs.production.min.js.map +0 -1
- package/dist/react-sync-ui.esm.js +0 -118
- package/dist/react-sync-ui.esm.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,8 +1,196 @@
|
|
|
1
|
+
import { Component, useCallback, useEffect, useMemo, useReducer, useRef, useState, useSyncExternalStore } from "react";
|
|
2
|
+
import { jsx } from "react/jsx-runtime";
|
|
3
|
+
//#region src/syncUI.tsx
|
|
4
|
+
var isDev = process.env.NODE_ENV !== "production";
|
|
5
|
+
var entrySeq = 0;
|
|
6
|
+
var defaultRejectReason = () => /* @__PURE__ */ new Error("react-sync-ui: rejected without a reason");
|
|
7
|
+
/**
|
|
8
|
+
* The queue lives OUTSIDE React. Every mutation happens in an event handler, an
|
|
9
|
+
* async continuation or a commit-phase lifecycle (the error boundary's
|
|
10
|
+
* `componentDidCatch`), never during render and never inside a setState
|
|
11
|
+
* updater, so StrictMode's double render / double updater passes and HMR
|
|
12
|
+
* remounts can neither duplicate nor lose a promise settlement. The
|
|
13
|
+
* commit-phase case is safe because a mutation only schedules a rerender
|
|
14
|
+
* through the subscription; it never runs while React is rendering.
|
|
15
|
+
*/
|
|
16
|
+
var createQueueStore = () => {
|
|
17
|
+
let queue = [];
|
|
18
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
19
|
+
const emit = () => {
|
|
20
|
+
listeners.forEach((listener) => listener());
|
|
21
|
+
};
|
|
22
|
+
const subscribe = (listener) => {
|
|
23
|
+
listeners.add(listener);
|
|
24
|
+
return () => {
|
|
25
|
+
listeners.delete(listener);
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
const getHead = () => queue[0] ?? null;
|
|
29
|
+
const push = (type, data) => new Promise((resolve, reject) => {
|
|
30
|
+
queue = [...queue, {
|
|
31
|
+
id: ++entrySeq,
|
|
32
|
+
type,
|
|
33
|
+
data,
|
|
34
|
+
resolve,
|
|
35
|
+
reject
|
|
36
|
+
}];
|
|
37
|
+
emit();
|
|
38
|
+
});
|
|
39
|
+
const settle = (entry, run) => {
|
|
40
|
+
const next = queue.filter((item) => item !== entry);
|
|
41
|
+
if (next.length === queue.length) return;
|
|
42
|
+
queue = next;
|
|
43
|
+
emit();
|
|
44
|
+
run(entry);
|
|
45
|
+
};
|
|
46
|
+
const drain = (reason) => {
|
|
47
|
+
if (queue.length === 0) return;
|
|
48
|
+
const abandoned = queue;
|
|
49
|
+
queue = [];
|
|
50
|
+
emit();
|
|
51
|
+
abandoned.forEach((item) => item.reject(reason));
|
|
52
|
+
};
|
|
53
|
+
return {
|
|
54
|
+
subscribe,
|
|
55
|
+
emit,
|
|
56
|
+
getHead,
|
|
57
|
+
push,
|
|
58
|
+
drain,
|
|
59
|
+
size: () => queue.length,
|
|
60
|
+
resolveEntry: (entry, value) => settle(entry, (item) => item.resolve(value)),
|
|
61
|
+
rejectEntry: (entry, reason) => settle(entry, (item) => item.reject(reason ?? defaultRejectReason()))
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
var getNullSnapshot = () => null;
|
|
65
|
+
var defaultQueueType = Symbol("usePromiseQueue");
|
|
66
|
+
var usePromiseQueue = () => {
|
|
67
|
+
const [store] = useState(() => createQueueStore());
|
|
68
|
+
const head = useSyncExternalStore(store.subscribe, store.getHead, getNullSnapshot);
|
|
69
|
+
const push = useCallback((data) => store.push(defaultQueueType, data), [store]);
|
|
70
|
+
const drainPending = useRef(false);
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
drainPending.current = false;
|
|
73
|
+
return () => {
|
|
74
|
+
drainPending.current = true;
|
|
75
|
+
queueMicrotask(() => {
|
|
76
|
+
if (!drainPending.current) return;
|
|
77
|
+
drainPending.current = false;
|
|
78
|
+
store.drain(/* @__PURE__ */ new Error("react-sync-ui: usePromiseQueue unmounted with pending items"));
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
}, [store]);
|
|
82
|
+
return useMemo(() => ({
|
|
83
|
+
head: head ? {
|
|
84
|
+
data: head.data,
|
|
85
|
+
resolve: (value) => store.resolveEntry(head, value),
|
|
86
|
+
reject: (reason) => store.rejectEntry(head, reason)
|
|
87
|
+
} : void 0,
|
|
88
|
+
push
|
|
89
|
+
}), [
|
|
90
|
+
head,
|
|
91
|
+
push,
|
|
92
|
+
store
|
|
93
|
+
]);
|
|
94
|
+
};
|
|
95
|
+
var SyncUIErrorBoundary = class extends Component {
|
|
96
|
+
constructor(..._args) {
|
|
97
|
+
super(..._args);
|
|
98
|
+
this.state = { failed: false };
|
|
99
|
+
}
|
|
100
|
+
static getDerivedStateFromError() {
|
|
101
|
+
return { failed: true };
|
|
102
|
+
}
|
|
103
|
+
componentDidCatch(error, _info) {
|
|
104
|
+
this.props.onError(this.props.entry, error);
|
|
105
|
+
}
|
|
106
|
+
render() {
|
|
107
|
+
return this.state.failed ? null : this.props.children;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
var syncUIFactory = () => {
|
|
111
|
+
const store = createQueueStore();
|
|
112
|
+
const components = /* @__PURE__ */ new Map();
|
|
113
|
+
const hosts = /* @__PURE__ */ new Set();
|
|
114
|
+
const primaryHost = () => hosts.values().next().value;
|
|
115
|
+
let hostEverMounted = false;
|
|
116
|
+
let warnedNoHost = false;
|
|
117
|
+
let warnedMultipleHosts = false;
|
|
118
|
+
let noHostTimer;
|
|
119
|
+
const scheduleNoHostWarning = () => {
|
|
120
|
+
if (!isDev || hostEverMounted || warnedNoHost || noHostTimer) return;
|
|
121
|
+
if (typeof window === "undefined") return;
|
|
122
|
+
noHostTimer = setTimeout(() => {
|
|
123
|
+
noHostTimer = void 0;
|
|
124
|
+
if (hostEverMounted || store.size() === 0) return;
|
|
125
|
+
warnedNoHost = true;
|
|
126
|
+
console.error("[react-sync-ui] a sync UI has been pending for 3s and no <SyncUI /> is mounted. Render <SyncUI /> once, near the root of your app.");
|
|
127
|
+
}, 3e3);
|
|
128
|
+
};
|
|
129
|
+
const makeSyncUI = (Component) => {
|
|
130
|
+
const type = Symbol(Component.displayName || Component.name || "SyncUI");
|
|
131
|
+
components.set(type, Component);
|
|
132
|
+
return (input) => {
|
|
133
|
+
scheduleNoHostWarning();
|
|
134
|
+
return store.push(type, input);
|
|
135
|
+
};
|
|
136
|
+
};
|
|
137
|
+
const SyncUI = () => {
|
|
138
|
+
const [token] = useState(() => ({}));
|
|
139
|
+
const [, rerender] = useReducer((n) => n + 1, 0);
|
|
140
|
+
const getSnapshot = useCallback(() => primaryHost() === token ? store.getHead() : null, [token]);
|
|
141
|
+
const head = useSyncExternalStore(store.subscribe, getSnapshot, getNullSnapshot);
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
hosts.add(token);
|
|
144
|
+
hostEverMounted = true;
|
|
145
|
+
if (noHostTimer !== void 0) {
|
|
146
|
+
clearTimeout(noHostTimer);
|
|
147
|
+
noHostTimer = void 0;
|
|
148
|
+
}
|
|
149
|
+
store.emit();
|
|
150
|
+
if (store.getHead()) rerender();
|
|
151
|
+
let warnTimer;
|
|
152
|
+
if (isDev) warnTimer = setTimeout(() => {
|
|
153
|
+
if (hosts.size > 1 && !warnedMultipleHosts) {
|
|
154
|
+
warnedMultipleHosts = true;
|
|
155
|
+
console.warn("[react-sync-ui] more than one <SyncUI /> of the same factory is mounted; only the first mounted one renders.");
|
|
156
|
+
}
|
|
157
|
+
}, 0);
|
|
158
|
+
return () => {
|
|
159
|
+
if (warnTimer !== void 0) clearTimeout(warnTimer);
|
|
160
|
+
hosts.delete(token);
|
|
161
|
+
store.emit();
|
|
162
|
+
};
|
|
163
|
+
}, [token]);
|
|
164
|
+
const Dialog = head ? components.get(head.type) : void 0;
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
if (!head || components.get(head.type)) return;
|
|
167
|
+
if (isDev) console.error("[react-sync-ui] no component registered for the queued item", head.type);
|
|
168
|
+
store.rejectEntry(head, /* @__PURE__ */ new Error("react-sync-ui: no component registered for this sync UI"));
|
|
169
|
+
}, [head]);
|
|
170
|
+
const handlers = useMemo(() => head ? {
|
|
171
|
+
resolve: (value) => store.resolveEntry(head, value),
|
|
172
|
+
reject: (reason) => store.rejectEntry(head, reason)
|
|
173
|
+
} : null, [head]);
|
|
174
|
+
if (!head || !handlers || !Dialog) return null;
|
|
175
|
+
return /* @__PURE__ */ jsx(SyncUIErrorBoundary, {
|
|
176
|
+
entry: head,
|
|
177
|
+
onError: (entry, error) => store.rejectEntry(entry, error),
|
|
178
|
+
children: /* @__PURE__ */ jsx(Dialog, {
|
|
179
|
+
data: head.data,
|
|
180
|
+
resolve: handlers.resolve,
|
|
181
|
+
reject: handlers.reject
|
|
182
|
+
})
|
|
183
|
+
}, head.id);
|
|
184
|
+
};
|
|
185
|
+
return {
|
|
186
|
+
makeSyncUI,
|
|
187
|
+
SyncUI
|
|
188
|
+
};
|
|
189
|
+
};
|
|
190
|
+
//#endregion
|
|
191
|
+
//#region src/index.ts
|
|
192
|
+
var { makeSyncUI, SyncUI } = syncUIFactory();
|
|
193
|
+
//#endregion
|
|
194
|
+
export { SyncUI, makeSyncUI, syncUIFactory, usePromiseQueue };
|
|
1
195
|
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
if (process.env.NODE_ENV === 'production') {
|
|
5
|
-
module.exports = require('./react-sync-ui.cjs.production.min.js')
|
|
6
|
-
} else {
|
|
7
|
-
module.exports = require('./react-sync-ui.cjs.development.js')
|
|
8
|
-
}
|
|
196
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/syncUI.tsx","../src/index.ts"],"sourcesContent":["import {\n Component,\n useCallback,\n useEffect,\n useMemo,\n useReducer,\n useRef,\n useState,\n useSyncExternalStore\n} from \"react\";\nimport type { ComponentType, ErrorInfo, ReactElement, ReactNode } from \"react\";\n\n// The library build leaves `process.env.NODE_ENV` untouched on purpose so the\n// consumer's bundler decides, exactly like React itself. It has to stay a\n// bare read: a `typeof process` guard is NOT replaced by bundlers, and a\n// browser has no `process`, so the guard would silently disable every dev\n// warning under Vite or webpack dev. Wrapping it in an IIFE or try/catch\n// stops the minifier from folding it, and the warning strings would ship to\n// production. The trade-off is the same one React makes: importing the\n// package with no bundler and no `process` global throws at module scope.\ndeclare const process: { env: { NODE_ENV?: string } };\nconst isDev = process.env.NODE_ENV !== \"production\";\n\n// ------------------------------------------------------------------------------------\n// public types\n\nexport type SyncUIProps<InputData, ResolveValue = void> = {\n data: InputData;\n resolve: (value: ResolveValue) => void;\n reject: (reason?: unknown) => void;\n};\n\n// ComponentType, not a bare function type: React 19's FC returns\n// `ReactNode | Promise<ReactNode>`, so `React.FC<SyncUIProps<...>>`, memo(),\n// forwardRef() and class components all have to be accepted here.\nexport type SyncUIComponent<InputData, ResolveValue = void> = ComponentType<\n SyncUIProps<InputData, ResolveValue>\n>;\n\n// The awaitable function `makeSyncUI` returns. Named, so a wrapper, a context\n// value or a props type can refer to it instead of re-spelling the signature.\nexport type SyncUIFunction<InputData, ResolveValue = void> = (\n input: InputData\n) => Promise<ResolveValue>;\n\n// `head` is exactly what a sync component receives, so it reuses SyncUIProps.\nexport type PromiseQueueAPI<InputData, ResolveValue = void> = {\n head?: SyncUIProps<InputData, ResolveValue>;\n push: (data: InputData) => Promise<ResolveValue>;\n};\n\nexport type SyncUIFactory = {\n makeSyncUI: <InputData, ResolveValue = void>(\n Component: SyncUIComponent<InputData, ResolveValue>\n ) => SyncUIFunction<InputData, ResolveValue>;\n SyncUI: () => ReactElement | null;\n};\n\n// ------------------------------------------------------------------------------------\n// queue store\n\ntype Listener = () => void;\n\ntype Entry<Data, ResolveValue> = {\n readonly id: number;\n readonly type: symbol;\n readonly data: Data;\n readonly resolve: (value: ResolveValue) => void;\n readonly reject: (reason?: unknown) => void;\n};\n\n// Monotonic, so React keys are unique per queued item (no Math.random()).\nlet entrySeq = 0;\n\nconst defaultRejectReason = () =>\n new Error(\"react-sync-ui: rejected without a reason\");\n\n/**\n * The queue lives OUTSIDE React. Every mutation happens in an event handler, an\n * async continuation or a commit-phase lifecycle (the error boundary's\n * `componentDidCatch`), never during render and never inside a setState\n * updater, so StrictMode's double render / double updater passes and HMR\n * remounts can neither duplicate nor lose a promise settlement. The\n * commit-phase case is safe because a mutation only schedules a rerender\n * through the subscription; it never runs while React is rendering.\n */\nconst createQueueStore = <Data, ResolveValue>() => {\n let queue: readonly Entry<Data, ResolveValue>[] = [];\n const listeners = new Set<Listener>();\n\n const emit = () => {\n listeners.forEach(listener => listener());\n };\n\n // Stable identity, so React never re-subscribes.\n const subscribe = (listener: Listener) => {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n };\n\n // useSyncExternalStore needs a cached snapshot: `queue` is only ever\n // replaced, never mutated, so the head entry keeps its identity until it\n // actually leaves the queue.\n const getHead = (): Entry<Data, ResolveValue> | null => queue[0] ?? null;\n\n const push = (type: symbol, data: Data) =>\n new Promise<ResolveValue>((resolve, reject) => {\n // The Promise executor runs synchronously, so the entry is queued\n // before push() returns.\n queue = [...queue, { id: ++entrySeq, type, data, resolve, reject }];\n emit();\n });\n\n // Settles exactly once. Membership in the queue is the \"not yet settled\"\n // flag, so a second call, or a call from a stale closure, is a no-op.\n const settle = (\n entry: Entry<Data, ResolveValue>,\n run: (entry: Entry<Data, ResolveValue>) => void\n ) => {\n const next = queue.filter(item => item !== entry);\n if (next.length === queue.length) return;\n queue = next;\n emit();\n run(entry);\n };\n\n // Rejects everything still queued at once (used when the owner of the queue\n // goes away), so no caller is left awaiting a promise nobody can settle.\n const drain = (reason: unknown) => {\n if (queue.length === 0) return;\n const abandoned = queue;\n queue = [];\n emit();\n abandoned.forEach(item => item.reject(reason));\n };\n\n return {\n subscribe,\n emit,\n getHead,\n push,\n drain,\n size: () => queue.length,\n resolveEntry: (entry: Entry<Data, ResolveValue>, value: ResolveValue) =>\n settle(entry, item => item.resolve(value)),\n // `reject()` with no reason would reject with `undefined`, so the\n // idiomatic `catch (error) { toast(error.message) }` would throw on top of\n // the cancellation. One default, applied for every caller.\n rejectEntry: (entry: Entry<Data, ResolveValue>, reason?: unknown) =>\n settle(entry, item => item.reject(reason ?? defaultRejectReason()))\n };\n};\n\nconst getNullSnapshot = () => null;\n\n// ------------------------------------------------------------------------------------\n// usePromiseQueue\n\nconst defaultQueueType = Symbol(\"usePromiseQueue\");\n\nexport const usePromiseQueue = <\n InputData,\n ResolveValue = void\n>(): PromiseQueueAPI<InputData, ResolveValue> => {\n // Lazy initializer: StrictMode may run it twice, but it only allocates.\n const [store] = useState(() => createQueueStore<InputData, ResolveValue>());\n const head = useSyncExternalStore(\n store.subscribe,\n store.getHead,\n getNullSnapshot\n );\n const push = useCallback(\n (data: InputData) => store.push(defaultQueueType, data),\n [store]\n );\n\n // Unlike the factory queue (which outlives every host), this store is owned\n // by the component, so anything still queued when it unmounts could never be\n // settled by anyone: every `await push(...)` would stay suspended forever.\n // The drain is deferred to a microtask and cancelled if the effect runs\n // again, because StrictMode (and Fast Refresh) replay mount/unmount/mount\n // synchronously: draining on that simulated unmount would reject items a\n // sibling had just pushed from its own mount effect.\n const drainPending = useRef(false);\n useEffect(() => {\n drainPending.current = false;\n return () => {\n drainPending.current = true;\n queueMicrotask(() => {\n if (!drainPending.current) return;\n drainPending.current = false;\n store.drain(\n new Error(\n \"react-sync-ui: usePromiseQueue unmounted with pending items\"\n )\n );\n });\n };\n }, [store]);\n\n return useMemo(\n () => ({\n head: head\n ? {\n data: head.data,\n // Bound to THIS entry: stale or repeated calls are no-ops.\n resolve: (value: ResolveValue) => store.resolveEntry(head, value),\n reject: (reason?: unknown) => store.rejectEntry(head, reason)\n }\n : undefined,\n push\n }),\n [head, push, store]\n );\n};\n\n// ------------------------------------------------------------------------------------\n// syncUIFactory\n\n// The registry has to accept components of every Data/Result shape.\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\ntype AnySyncUIComponent = SyncUIComponent<any, any>;\n\n// A dialog that throws during render used to be a poison pill: the app's own\n// error boundary caught it, <SyncUI /> unmounted, and the entry stayed at the\n// head of the queue forever, hanging its own promise and every queued one.\n// This boundary keeps the failure local: the entry is rejected (the caller's\n// `await` throws, which is the error channel) and the queue moves on. It is\n// remounted per entry via `key`, so the next dialog renders fresh.\ntype SyncUIBoundaryProps<Data, ResolveValue> = {\n entry: Entry<Data, ResolveValue>;\n onError: (entry: Entry<Data, ResolveValue>, error: unknown) => void;\n children: ReactNode;\n};\n\ntype SyncUIBoundaryState = { failed: boolean };\n\nclass SyncUIErrorBoundary extends Component<\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n SyncUIBoundaryProps<any, any>,\n SyncUIBoundaryState\n> {\n state: SyncUIBoundaryState = { failed: false };\n\n static getDerivedStateFromError(): SyncUIBoundaryState {\n return { failed: true };\n }\n\n componentDidCatch(error: unknown, _info: ErrorInfo) {\n // Not re-thrown on purpose: the promise rejection is the error channel,\n // and React already logs the caught error itself in development.\n this.props.onError(this.props.entry, error);\n }\n\n render() {\n return this.state.failed ? null : this.props.children;\n }\n}\n\nexport const syncUIFactory = (): SyncUIFactory => {\n const store = createQueueStore<unknown, unknown>();\n\n // Live registry, resolved at render time. A module that calls makeSyncUI\n // after <SyncUI /> has mounted (lazy chunk, Vite HMR) simply lands here.\n const components = new Map<symbol, AnySyncUIComponent>();\n\n // Every mounted <SyncUI /> instance, in mount order. A Set instead of a\n // boolean or a single ref: a replacement instance can mount before the old\n // one's cleanup runs (Fast Refresh remounts, a second root, route layouts),\n // and that overlap must not break anything.\n const hosts = new Set<object>();\n const primaryHost = () => hosts.values().next().value;\n\n let hostEverMounted = false;\n let warnedNoHost = false;\n let warnedMultipleHosts = false;\n let noHostTimer: ReturnType<typeof setTimeout> | undefined;\n\n // Pushing before <SyncUI /> is mounted is a race, not a configuration error\n // (child effects run before parent effects), so items just wait in the\n // queue. A silent hang would hide a forgotten <SyncUI />, hence the dev\n // warning if nothing has mounted after a while.\n const scheduleNoHostWarning = () => {\n if (!isDev || hostEverMounted || warnedNoHost || noHostTimer) return;\n // On the server <SyncUI /> never mounts, so the warning would be noise\n // and the timer would keep the process alive.\n if (typeof window === \"undefined\") return;\n noHostTimer = setTimeout(() => {\n noHostTimer = undefined;\n if (hostEverMounted || store.size() === 0) return;\n warnedNoHost = true;\n console.error(\n \"[react-sync-ui] a sync UI has been pending for 3s and no <SyncUI /> \" +\n \"is mounted. Render <SyncUI /> once, near the root of your app.\"\n );\n }, 3000);\n };\n\n const makeSyncUI = <InputData, ResolveValue = void>(\n Component: SyncUIComponent<InputData, ResolveValue>\n ): SyncUIFunction<InputData, ResolveValue> => {\n const type = Symbol(\n (Component as { displayName?: string }).displayName ||\n Component.name ||\n \"SyncUI\"\n );\n components.set(type, Component as AnySyncUIComponent);\n\n return (input: InputData): Promise<ResolveValue> => {\n scheduleNoHostWarning();\n return store.push(type, input) as Promise<ResolveValue>;\n };\n };\n\n const SyncUI = (): ReactElement | null => {\n // Stable per-instance identity.\n const [token] = useState(() => ({}));\n const [, rerender] = useReducer((n: number) => n + 1, 0);\n\n // Only the first mounted host renders; the others stay empty so a dialog\n // is never shown twice. Declared BEFORE the registration effect so React\n // is subscribed by the time that effect emits.\n const getSnapshot = useCallback(\n () => (primaryHost() === token ? store.getHead() : null),\n [token]\n );\n const head = useSyncExternalStore(\n store.subscribe,\n getSnapshot,\n getNullSnapshot\n );\n\n useEffect(() => {\n hosts.add(token);\n hostEverMounted = true;\n // A host is here: the \"nothing ever mounted\" warning can no longer fire,\n // so the handle should not linger (it would hold a Node test run open).\n if (noHostTimer !== undefined) {\n clearTimeout(noHostTimer);\n noHostTimer = undefined;\n }\n store.emit();\n // React 19 <Activity mode=\"hidden\"> disconnects the store subscription\n // and re-shows with a stale cached snapshot, so emit() alone compares\n // equal and skips the render. A local state bump cannot be skipped.\n // Only worth it when there is something to re-read: mounting with an\n // empty queue is the common case and should not cost an extra render.\n if (store.getHead()) rerender();\n\n // Deferred one tick and cleared on cleanup, so the HMR overlap (new\n // instance mounted, old one not yet unmounted) never false-warns.\n let warnTimer: ReturnType<typeof setTimeout> | undefined;\n if (isDev) {\n warnTimer = setTimeout(() => {\n if (hosts.size > 1 && !warnedMultipleHosts) {\n warnedMultipleHosts = true;\n console.warn(\n \"[react-sync-ui] more than one <SyncUI /> of the same factory \" +\n \"is mounted; only the first mounted one renders.\"\n );\n }\n }, 0);\n }\n\n return () => {\n if (warnTimer !== undefined) clearTimeout(warnTimer);\n hosts.delete(token);\n // The queue stays intact; hand over to the next host, if any.\n store.emit();\n };\n }, [token]);\n\n const Dialog = head ? components.get(head.type) : undefined;\n\n // An entry whose component is missing can never be rendered, so \"log and\n // stall\" would block it AND everything queued behind it forever; reject it\n // instead and let the queue move on. Kept out of render so StrictMode\n // cannot run it twice. Not reachable through the public API, because\n // makeSyncUI registers the component in the same statement that mints its\n // symbol; this is the recovery path for a registry that lost the entry (a\n // module graph reset under a live queue).\n useEffect(() => {\n if (!head || components.get(head.type)) return;\n if (isDev) {\n console.error(\n \"[react-sync-ui] no component registered for the queued item\",\n head.type\n );\n }\n store.rejectEntry(\n head,\n new Error(\"react-sync-ui: no component registered for this sync UI\")\n );\n }, [head]);\n\n const handlers = useMemo(\n () =>\n head\n ? {\n resolve: (value: unknown) => store.resolveEntry(head, value),\n reject: (reason?: unknown) => store.rejectEntry(head, reason)\n }\n : null,\n [head]\n );\n\n if (!head || !handlers || !Dialog) return null;\n\n // The key changes per queued item, so two consecutive items of the same\n // component get a fresh instance (no leaked local state) and a boundary\n // that caught an error is reset for the next one.\n // `Dialog` is a registry lookup, not a component created during render;\n // the lint rule cannot tell the difference.\n return (\n <SyncUIErrorBoundary\n key={head.id}\n entry={head}\n onError={(entry, error) => store.rejectEntry(entry, error)}\n >\n {/* eslint-disable-next-line react-hooks/static-components */}\n <Dialog\n data={head.data}\n resolve={handlers.resolve}\n reject={handlers.reject}\n />\n </SyncUIErrorBoundary>\n );\n };\n\n return { makeSyncUI, SyncUI };\n};\n","import { syncUIFactory } from \"./syncUI.js\";\n\nexport { syncUIFactory, usePromiseQueue } from \"./syncUI.js\";\nexport type {\n PromiseQueueAPI,\n SyncUIComponent,\n SyncUIFactory,\n SyncUIFunction,\n SyncUIProps\n} from \"./syncUI.js\";\n\nexport const { makeSyncUI, SyncUI } = syncUIFactory();\n"],"mappings":"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"}
|
package/dist/syncUI.d.ts
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
1
|
+
import { ComponentType, ReactElement } from 'react';
|
|
2
|
+
export type SyncUIProps<InputData, ResolveValue = void> = {
|
|
3
|
+
data: InputData;
|
|
4
|
+
resolve: (value: ResolveValue) => void;
|
|
5
|
+
reject: (reason?: unknown) => void;
|
|
6
|
+
};
|
|
7
|
+
export type SyncUIComponent<InputData, ResolveValue = void> = ComponentType<SyncUIProps<InputData, ResolveValue>>;
|
|
8
|
+
export type SyncUIFunction<InputData, ResolveValue = void> = (input: InputData) => Promise<ResolveValue>;
|
|
9
|
+
export type PromiseQueueAPI<InputData, ResolveValue = void> = {
|
|
10
|
+
head?: SyncUIProps<InputData, ResolveValue>;
|
|
11
|
+
push: (data: InputData) => Promise<ResolveValue>;
|
|
12
|
+
};
|
|
13
|
+
export type SyncUIFactory = {
|
|
14
|
+
makeSyncUI: <InputData, ResolveValue = void>(Component: SyncUIComponent<InputData, ResolveValue>) => SyncUIFunction<InputData, ResolveValue>;
|
|
15
|
+
SyncUI: () => ReactElement | null;
|
|
16
|
+
};
|
|
17
|
+
export declare const usePromiseQueue: <InputData, ResolveValue = void>() => PromiseQueueAPI<InputData, ResolveValue>;
|
|
18
|
+
export declare const syncUIFactory: () => SyncUIFactory;
|
|
19
|
+
//# sourceMappingURL=syncUI.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"syncUI.d.ts","sourceRoot":"","sources":["../src/syncUI.tsx"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,aAAa,EAAa,YAAY,EAAa,MAAM,OAAO,CAAC;AAgB/E,MAAM,MAAM,WAAW,CAAC,SAAS,EAAE,YAAY,GAAG,IAAI,IAAI;IACxD,IAAI,EAAE,SAAS,CAAC;IAChB,OAAO,EAAE,CAAC,KAAK,EAAE,YAAY,KAAK,IAAI,CAAC;IACvC,MAAM,EAAE,CAAC,MAAM,CAAC,EAAE,OAAO,KAAK,IAAI,CAAC;CACpC,CAAC;AAKF,MAAM,MAAM,eAAe,CAAC,SAAS,EAAE,YAAY,GAAG,IAAI,IAAI,aAAa,CACzE,WAAW,CAAC,SAAS,EAAE,YAAY,CAAC,CACrC,CAAC;AAIF,MAAM,MAAM,cAAc,CAAC,SAAS,EAAE,YAAY,GAAG,IAAI,IAAI,CAC3D,KAAK,EAAE,SAAS,KACb,OAAO,CAAC,YAAY,CAAC,CAAC;AAG3B,MAAM,MAAM,eAAe,CAAC,SAAS,EAAE,YAAY,GAAG,IAAI,IAAI;IAC5D,IAAI,CAAC,EAAE,WAAW,CAAC,SAAS,EAAE,YAAY,CAAC,CAAC;IAC5C,IAAI,EAAE,CAAC,IAAI,EAAE,SAAS,KAAK,OAAO,CAAC,YAAY,CAAC,CAAC;CAClD,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG;IAC1B,UAAU,EAAE,CAAC,SAAS,EAAE,YAAY,GAAG,IAAI,EACzC,SAAS,EAAE,eAAe,CAAC,SAAS,EAAE,YAAY,CAAC,KAChD,cAAc,CAAC,SAAS,EAAE,YAAY,CAAC,CAAC;IAC7C,MAAM,EAAE,MAAM,YAAY,GAAG,IAAI,CAAC;CACnC,CAAC;AA0GF,eAAO,MAAM,eAAe,GAC1B,SAAS,EACT,YAAY,GAAG,IAAI,OAChB,eAAe,CAAC,SAAS,EAAE,YAAY,CAmD3C,CAAC;AA6CF,eAAO,MAAM,aAAa,QAAO,aA2KhC,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,55 +1,103 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-sync-ui",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Promise-based imperative React UI: await an alert, confirm or prompt like a function call.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/Svehla/react-sync-ui.git"
|
|
8
|
+
},
|
|
9
|
+
"homepage": "https://github.com/Svehla/react-sync-ui#readme",
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/Svehla/react-sync-ui/issues"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"react",
|
|
15
|
+
"promise",
|
|
16
|
+
"async",
|
|
17
|
+
"await",
|
|
18
|
+
"modal",
|
|
19
|
+
"dialog",
|
|
20
|
+
"confirm",
|
|
21
|
+
"prompt",
|
|
22
|
+
"alert",
|
|
23
|
+
"queue",
|
|
24
|
+
"hooks"
|
|
25
|
+
],
|
|
5
26
|
"license": "MIT",
|
|
6
27
|
"author": "Jakub Švehla",
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
"
|
|
28
|
+
"type": "module",
|
|
29
|
+
"sideEffects": false,
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"import": "./dist/index.js",
|
|
34
|
+
"default": "./dist/index.js"
|
|
35
|
+
},
|
|
36
|
+
"./package.json": "./package.json"
|
|
37
|
+
},
|
|
38
|
+
"main": "./dist/index.js",
|
|
39
|
+
"module": "./dist/index.js",
|
|
40
|
+
"types": "./dist/index.d.ts",
|
|
10
41
|
"files": [
|
|
11
42
|
"dist",
|
|
12
|
-
"src"
|
|
43
|
+
"src",
|
|
44
|
+
"CHANGELOG.md"
|
|
13
45
|
],
|
|
14
46
|
"scripts": {
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
47
|
+
"build": "vite build",
|
|
48
|
+
"dev": "vite build --watch",
|
|
49
|
+
"test": "vitest run",
|
|
50
|
+
"test:watch": "vitest",
|
|
51
|
+
"coverage": "vitest run --coverage",
|
|
52
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
53
|
+
"lint": "eslint . --max-warnings 0",
|
|
54
|
+
"lint:fix": "eslint . --fix",
|
|
55
|
+
"format": "prettier --write .",
|
|
56
|
+
"format:check": "prettier --check .",
|
|
20
57
|
"size": "size-limit",
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"
|
|
58
|
+
"check": "npm run lint && npm run typecheck && npm test && npm run build && publint --strict && attw --pack . --profile esm-only",
|
|
59
|
+
"prepare": "husky || true",
|
|
60
|
+
"prepublishOnly": "npm run check",
|
|
61
|
+
"prepack": "npm run build"
|
|
24
62
|
},
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
"pre-commit": "tsdx lint"
|
|
28
|
-
}
|
|
63
|
+
"peerDependencies": {
|
|
64
|
+
"react": "^18.0.0 || ^19.0.0"
|
|
29
65
|
},
|
|
30
66
|
"devDependencies": {
|
|
31
|
-
"@
|
|
32
|
-
"@
|
|
33
|
-
"@
|
|
34
|
-
"@
|
|
35
|
-
"@
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
"react": "
|
|
67
|
+
"@arethetypeswrong/cli": "^0.18.5",
|
|
68
|
+
"@eslint/js": "^10.0.1",
|
|
69
|
+
"@size-limit/preset-small-lib": "^13.0.3",
|
|
70
|
+
"@testing-library/dom": "^10.4.1",
|
|
71
|
+
"@testing-library/jest-dom": "^7.0.1",
|
|
72
|
+
"@testing-library/react": "^16.3.3",
|
|
73
|
+
"@testing-library/user-event": "^14.6.7",
|
|
74
|
+
"@types/react": "^19.2.18",
|
|
75
|
+
"@types/react-dom": "^19.2.5",
|
|
76
|
+
"@vitest/coverage-v8": "^4.1.11",
|
|
77
|
+
"eslint": "^10.9.1",
|
|
78
|
+
"eslint-config-prettier": "^10.1.8",
|
|
79
|
+
"eslint-plugin-react-hooks": "^7.1.1",
|
|
80
|
+
"husky": "^9.1.7",
|
|
81
|
+
"jsdom": "^30.0.1",
|
|
82
|
+
"prettier": "^3.9.6",
|
|
83
|
+
"publint": "^0.3.24",
|
|
84
|
+
"react": "^19.2.8",
|
|
85
|
+
"react-dom": "^19.2.8",
|
|
86
|
+
"size-limit": "^13.0.3",
|
|
87
|
+
"typescript": "5.9.3",
|
|
88
|
+
"typescript-eslint": "^8.69.0",
|
|
89
|
+
"unplugin-dts": "^1.1.0",
|
|
90
|
+
"vite": "^8.2.2",
|
|
91
|
+
"vitest": "^4.1.11"
|
|
50
92
|
},
|
|
51
93
|
"engines": {
|
|
52
|
-
"node": ">=
|
|
94
|
+
"node": "^20.19.0 || >=22.12.0"
|
|
53
95
|
},
|
|
54
|
-
"size-limit": [
|
|
96
|
+
"size-limit": [
|
|
97
|
+
{
|
|
98
|
+
"name": "ESM bundle (brotli)",
|
|
99
|
+
"path": "dist/index.js",
|
|
100
|
+
"limit": "2 kB"
|
|
101
|
+
}
|
|
102
|
+
]
|
|
55
103
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
-
import { syncUIFactory
|
|
2
|
-
|
|
1
|
+
import { syncUIFactory } from "./syncUI.js";
|
|
2
|
+
|
|
3
|
+
export { syncUIFactory, usePromiseQueue } from "./syncUI.js";
|
|
4
|
+
export type {
|
|
5
|
+
PromiseQueueAPI,
|
|
6
|
+
SyncUIComponent,
|
|
7
|
+
SyncUIFactory,
|
|
8
|
+
SyncUIFunction,
|
|
9
|
+
SyncUIProps
|
|
10
|
+
} from "./syncUI.js";
|
|
11
|
+
|
|
3
12
|
export const { makeSyncUI, SyncUI } = syncUIFactory();
|