@ttt-productions/upload-ui 0.7.12 → 0.7.13
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/dist/react/guard/index.d.ts +1 -1
- package/dist/react/guard/index.d.ts.map +1 -1
- package/dist/react/guard/index.js +1 -1
- package/dist/react/guard/index.js.map +1 -1
- package/dist/react/guarded-link.d.ts +2 -2
- package/dist/react/guarded-link.js.map +1 -1
- package/dist/react/local-upload-guard-provider.d.ts +53 -40
- package/dist/react/local-upload-guard-provider.d.ts.map +1 -1
- package/dist/react/local-upload-guard-provider.js +116 -58
- package/dist/react/local-upload-guard-provider.js.map +1 -1
- package/dist/react/use-guarded-navigation.d.ts +2 -2
- package/dist/react/use-guarded-navigation.js +5 -5
- package/dist/react/use-guarded-navigation.js.map +1 -1
- package/package.json +5 -5
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { LocalUploadGuardProvider, useLocalUploadGuard, useLocalUploadGuardContext, useOptionalLocalUploadGuard, type ConfirmNavigationOptions, type LocalUploadGuardProviderProps, } from '../local-upload-guard-provider.js';
|
|
1
|
+
export { LocalUploadGuardProvider, useLocalUploadGuard, useLocalUploadGuardContext, useOptionalLocalUploadGuard, useUnsavedWorkGuard, type ConfirmNavigationOptions, type LocalUploadGuardProviderProps, } from '../local-upload-guard-provider.js';
|
|
2
2
|
export { useGuardedNavigation } from '../use-guarded-navigation.js';
|
|
3
3
|
export { GuardedLink, type GuardedLinkProps, type GuardedLinkRenderArgs, } from '../guarded-link.js';
|
|
4
4
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/react/guard/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,wBAAwB,EACxB,mBAAmB,EACnB,0BAA0B,EAC1B,2BAA2B,EAC3B,KAAK,wBAAwB,EAC7B,KAAK,6BAA6B,GACnC,MAAM,mCAAmC,CAAC;AAC3C,OAAO,EAAE,oBAAoB,EAAE,MAAM,8BAA8B,CAAC;AACpE,OAAO,EACL,WAAW,EACX,KAAK,gBAAgB,EACrB,KAAK,qBAAqB,GAC3B,MAAM,oBAAoB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/react/guard/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,wBAAwB,EACxB,mBAAmB,EACnB,0BAA0B,EAC1B,2BAA2B,EAC3B,mBAAmB,EACnB,KAAK,wBAAwB,EAC7B,KAAK,6BAA6B,GACnC,MAAM,mCAAmC,CAAC;AAC3C,OAAO,EAAE,oBAAoB,EAAE,MAAM,8BAA8B,CAAC;AACpE,OAAO,EACL,WAAW,EACX,KAAK,gBAAgB,EACrB,KAAK,qBAAqB,GAC3B,MAAM,oBAAoB,CAAC"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { LocalUploadGuardProvider, useLocalUploadGuard, useLocalUploadGuardContext, useOptionalLocalUploadGuard, } from '../local-upload-guard-provider.js';
|
|
1
|
+
export { LocalUploadGuardProvider, useLocalUploadGuard, useLocalUploadGuardContext, useOptionalLocalUploadGuard, useUnsavedWorkGuard, } from '../local-upload-guard-provider.js';
|
|
2
2
|
export { useGuardedNavigation } from '../use-guarded-navigation.js';
|
|
3
3
|
export { GuardedLink, } from '../guarded-link.js';
|
|
4
4
|
//# sourceMappingURL=index.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/react/guard/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,wBAAwB,EACxB,mBAAmB,EACnB,0BAA0B,EAC1B,2BAA2B,
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/react/guard/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,wBAAwB,EACxB,mBAAmB,EACnB,0BAA0B,EAC1B,2BAA2B,EAC3B,mBAAmB,GAGpB,MAAM,mCAAmC,CAAC;AAC3C,OAAO,EAAE,oBAAoB,EAAE,MAAM,8BAA8B,CAAC;AACpE,OAAO,EACL,WAAW,GAGZ,MAAM,oBAAoB,CAAC","sourcesContent":["export {\n LocalUploadGuardProvider,\n useLocalUploadGuard,\n useLocalUploadGuardContext,\n useOptionalLocalUploadGuard,\n useUnsavedWorkGuard,\n type ConfirmNavigationOptions,\n type LocalUploadGuardProviderProps,\n} from '../local-upload-guard-provider.js';\nexport { useGuardedNavigation } from '../use-guarded-navigation.js';\nexport {\n GuardedLink,\n type GuardedLinkProps,\n type GuardedLinkRenderArgs,\n} from '../guarded-link.js';\n"]}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Render-prop link wrapper that guards navigation
|
|
3
|
-
*
|
|
2
|
+
* Render-prop link wrapper that guards navigation while an upload or unsaved
|
|
3
|
+
* work is registered. The consumer owns the actual navigator component (Next.js Link,
|
|
4
4
|
* React Router Link, plain anchor, etc.) — this wrapper only injects the
|
|
5
5
|
* onClick interceptor.
|
|
6
6
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"guarded-link.js","sourceRoot":"","sources":["../../src/react/guarded-link.tsx"],"names":[],"mappings":"AAAA,YAAY,CAAC;;AA6Bb,OAAO,EAAE,mBAAmB,EAAE,MAAM,kCAAkC,CAAC;AAcvE,MAAM,UAAU,WAAW,CAAC,KAAuB;IACjD,MAAM,EAAE,QAAQ,EAAE,UAAU,EAAE,GAAG,KAAK,CAAC;IACvC,MAAM,EAAE,iBAAiB,EAAE,GAAG,mBAAmB,EAAE,CAAC;IAEpD,MAAM,OAAO,GAAG,CAAC,KAA8B,EAAE,EAAE;QACjD,IAAI,CAAC,iBAAiB,EAAE,EAAE,CAAC;YACzB,KAAK,CAAC,cAAc,EAAE,CAAC;QACzB,CAAC;IACH,CAAC,CAAC;IAEF,OAAO,4BAAG,UAAU,CAAC,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC,GAAI,CAAC;AAClD,CAAC","sourcesContent":["'use client';\n\n/**\n * Render-prop link wrapper that guards navigation
|
|
1
|
+
{"version":3,"file":"guarded-link.js","sourceRoot":"","sources":["../../src/react/guarded-link.tsx"],"names":[],"mappings":"AAAA,YAAY,CAAC;;AA6Bb,OAAO,EAAE,mBAAmB,EAAE,MAAM,kCAAkC,CAAC;AAcvE,MAAM,UAAU,WAAW,CAAC,KAAuB;IACjD,MAAM,EAAE,QAAQ,EAAE,UAAU,EAAE,GAAG,KAAK,CAAC;IACvC,MAAM,EAAE,iBAAiB,EAAE,GAAG,mBAAmB,EAAE,CAAC;IAEpD,MAAM,OAAO,GAAG,CAAC,KAA8B,EAAE,EAAE;QACjD,IAAI,CAAC,iBAAiB,EAAE,EAAE,CAAC;YACzB,KAAK,CAAC,cAAc,EAAE,CAAC;QACzB,CAAC;IACH,CAAC,CAAC;IAEF,OAAO,4BAAG,UAAU,CAAC,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC,GAAI,CAAC;AAClD,CAAC","sourcesContent":["'use client';\n\n/**\n * Render-prop link wrapper that guards navigation while an upload or unsaved\n * work is registered. The consumer owns the actual navigator component (Next.js Link,\n * React Router Link, plain anchor, etc.) — this wrapper only injects the\n * onClick interceptor.\n *\n * Why render-prop and not `as={Link}`? Polymorphic `as` props create\n * notoriously bad TypeScript inference and force the wrapper to forward all\n * link-specific props. Render-prop keeps the consumer in full control of the\n * link API, costs one extra wrapper closure, and keeps upload-ui agnostic of\n * Next.js / React Router / etc.\n *\n * Design note: the design spec lists `<GuardedNavigationButton>` as a second\n * component. We deliberately don't ship it — buttons that navigate use\n * `useGuardedNavigation()` directly in one line. A second component would\n * split the same surface for no payoff.\n *\n * Usage:\n *\n * <GuardedLink renderLink={({ onClick, children }) => (\n * <Link href=\"/your/destination\" onClick={onClick}>{children}</Link>\n * )}>\n * View all uploads →\n * </GuardedLink>\n */\n\nimport type { MouseEvent, ReactNode } from 'react';\nimport { useLocalUploadGuard } from './local-upload-guard-provider.js';\n\nexport interface GuardedLinkRenderArgs {\n /** Click handler the consumer MUST attach to its underlying link element. */\n onClick: (event: MouseEvent<HTMLElement>) => void;\n /** The children passed to `<GuardedLink>` — forward them to the link. */\n children: ReactNode;\n}\n\nexport interface GuardedLinkProps {\n children: ReactNode;\n renderLink: (args: GuardedLinkRenderArgs) => ReactNode;\n}\n\nexport function GuardedLink(props: GuardedLinkProps) {\n const { children, renderLink } = props;\n const { confirmNavigation } = useLocalUploadGuard();\n\n const onClick = (event: MouseEvent<HTMLElement>) => {\n if (!confirmNavigation()) {\n event.preventDefault();\n }\n };\n\n return <>{renderLink({ onClick, children })}</>;\n}\n"]}
|
|
@@ -1,25 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* The navigation and leave guard: warns before the user leaves while work that leaving would lose
|
|
3
|
+
* is registered — a LOCAL byte upload in flight, or an editor's unsaved input.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* Uploads: phases `preparing` and `uploading` only. `finalizing` and backend `pendingMedia`
|
|
6
|
+
* processing are safe to leave (the bytes are already in Storage), so they are not registered.
|
|
7
|
+
* Unsaved work: an editor registers while it holds input its user has not saved, and unregisters
|
|
8
|
+
* when the input is saved, discarded, or the editor unmounts.
|
|
7
9
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* Storage and the only remaining work is the `startUpload` callable.
|
|
12
|
-
* - Does NOT cover backend `pendingMedia` status (`pending` /
|
|
13
|
-
* `processing`). Those run server-side and are safe to leave.
|
|
10
|
+
* While anything is registered the provider installs ONE `beforeunload` listener (tab close,
|
|
11
|
+
* reload, full-page navigation), and `confirmNavigation()` asks before an in-app navigation. It is
|
|
12
|
+
* the one leave guard: an editor never installs its own `beforeunload` handler (FRONTEND-207).
|
|
14
13
|
*
|
|
15
|
-
* Consumers register with a stable id
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* `finalizing`/success/error/cancel/unmount.
|
|
19
|
-
*
|
|
20
|
-
* Public hook: `useLocalUploadGuard()` returns
|
|
21
|
-
* `{ activeUploadCount, registerUpload, unregisterUpload }`. Throws if
|
|
22
|
-
* used outside a `LocalUploadGuardProvider`.
|
|
14
|
+
* Consumers register with a stable id per piece of work; registering a registered id, or
|
|
15
|
+
* unregistering an absent one, is a no-op. Uploads and unsaved work are counted separately, so the
|
|
16
|
+
* same id may be both.
|
|
23
17
|
*/
|
|
24
18
|
import { type ReactNode } from 'react';
|
|
25
19
|
export interface ConfirmNavigationOptions {
|
|
@@ -33,43 +27,50 @@ export interface ConfirmNavigationOptions {
|
|
|
33
27
|
fullPageNavigation?: boolean;
|
|
34
28
|
}
|
|
35
29
|
interface LocalUploadGuardContextValue {
|
|
30
|
+
/** Registered local uploads. */
|
|
36
31
|
activeUploadCount: number;
|
|
32
|
+
/** Registered pieces of unsaved work. */
|
|
33
|
+
unsavedWorkCount: number;
|
|
34
|
+
/** Everything the guard warns about: uploads plus unsaved work. Zero means leaving asks nothing. */
|
|
35
|
+
guardedWorkCount: number;
|
|
37
36
|
registerUpload: (id: string) => void;
|
|
38
37
|
unregisterUpload: (id: string) => void;
|
|
39
|
-
|
|
38
|
+
registerUnsavedWork: (id: string) => void;
|
|
39
|
+
unregisterUnsavedWork: (id: string) => void;
|
|
40
|
+
/** Returns true iff an upload or unsaved work is currently registered. Pure read. */
|
|
40
41
|
shouldConfirmNavigation: () => boolean;
|
|
41
42
|
/**
|
|
42
|
-
* Shows a confirm() dialog with the
|
|
43
|
-
* choice. Safe to call when
|
|
43
|
+
* Shows a confirm() dialog with the message for what is registered and returns the user's
|
|
44
|
+
* choice. Safe to call when nothing is registered — returns true without prompting.
|
|
44
45
|
*
|
|
45
|
-
* With `{ fullPageNavigation: true }` and a result of true (confirmed, or
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
46
|
+
* With `{ fullPageNavigation: true }` and a result of true (confirmed, or nothing registered),
|
|
47
|
+
* arms a ONE-SHOT bypass: the next `beforeunload` is not prevented, so a confirmed full-page
|
|
48
|
+
* navigation does not prompt twice. A declined confirm never arms it; the bypass is cleared when
|
|
49
|
+
* it is consumed, when new work (an upload or unsaved work) registers, and on `pageshow` (a
|
|
50
|
+
* back/forward-cache return).
|
|
50
51
|
*/
|
|
51
52
|
confirmNavigation: (options?: ConfirmNavigationOptions) => boolean;
|
|
52
53
|
}
|
|
53
54
|
export interface LocalUploadGuardProviderProps {
|
|
54
55
|
/**
|
|
55
|
-
* Message
|
|
56
|
-
*
|
|
57
|
-
*
|
|
56
|
+
* Message the browser is given when the user closes the tab, reloads, or leaves the page while
|
|
57
|
+
* work is registered. Modern browsers show their own generic dialog and ignore it; when omitted,
|
|
58
|
+
* the default for what is registered is used.
|
|
58
59
|
*/
|
|
59
60
|
beforeUnloadMessage?: string;
|
|
60
|
-
/**
|
|
61
|
-
* Message shown by `confirmNavigation()` when an upload is in progress and
|
|
62
|
-
* the user attempts to navigate away within the app. Defaults to a
|
|
63
|
-
* reasonable English string.
|
|
64
|
-
*/
|
|
61
|
+
/** `confirmNavigation()`'s message while an upload is in progress (and no unsaved work). */
|
|
65
62
|
navigationConfirmMessage?: string;
|
|
63
|
+
/** `confirmNavigation()`'s message while unsaved work is registered (and no upload). */
|
|
64
|
+
unsavedWorkConfirmMessage?: string;
|
|
65
|
+
/** `confirmNavigation()`'s message while both an upload and unsaved work are registered. */
|
|
66
|
+
uploadAndUnsavedWorkConfirmMessage?: string;
|
|
66
67
|
children: ReactNode;
|
|
67
68
|
}
|
|
68
69
|
export declare function LocalUploadGuardProvider(props: LocalUploadGuardProviderProps): import("react").JSX.Element;
|
|
69
70
|
/**
|
|
70
|
-
* Low-level accessor for the
|
|
71
|
-
*
|
|
72
|
-
*
|
|
71
|
+
* Low-level accessor for the guard context. Throws if used outside a
|
|
72
|
+
* `LocalUploadGuardProvider`. Most consumers should use `useLocalUploadGuard`
|
|
73
|
+
* (the public re-export below) instead.
|
|
73
74
|
*/
|
|
74
75
|
export declare function useLocalUploadGuardContext(): LocalUploadGuardContextValue;
|
|
75
76
|
/**
|
|
@@ -80,9 +81,7 @@ export declare function useLocalUploadGuardContext(): LocalUploadGuardContextVal
|
|
|
80
81
|
*/
|
|
81
82
|
export declare function useOptionalLocalUploadGuard(): LocalUploadGuardContextValue | null;
|
|
82
83
|
/**
|
|
83
|
-
* Public hook for the
|
|
84
|
-
*
|
|
85
|
-
* Returns `{ activeUploadCount, registerUpload, unregisterUpload }`.
|
|
84
|
+
* Public hook for the navigation and leave guard.
|
|
86
85
|
*
|
|
87
86
|
* Typical usage in an upload flow:
|
|
88
87
|
*
|
|
@@ -93,7 +92,21 @@ export declare function useOptionalLocalUploadGuard(): LocalUploadGuardContextVa
|
|
|
93
92
|
* return () => unregisterUpload(uploadId);
|
|
94
93
|
* }
|
|
95
94
|
* }, [phase, uploadId, registerUpload, unregisterUpload]);
|
|
95
|
+
*
|
|
96
|
+
* An editor holding input uses `useUnsavedWorkGuard(hasUnsavedWork)` instead.
|
|
96
97
|
*/
|
|
97
98
|
export declare function useLocalUploadGuard(): LocalUploadGuardContextValue;
|
|
99
|
+
/**
|
|
100
|
+
* Registers the calling editor's unsaved input with the leave guard while `hasUnsavedWork` is
|
|
101
|
+
* true, and unregisters it when that turns false or the editor unmounts. While registered, closing
|
|
102
|
+
* the tab, reloading, a full-page navigation, and every guarded in-app navigation ask first.
|
|
103
|
+
* Throws outside a `LocalUploadGuardProvider`.
|
|
104
|
+
*
|
|
105
|
+
* Returns `release`, which unregisters at once: an editor that saves and then navigates in the
|
|
106
|
+
* same handler calls it after the save, because its dirty flag clears only on the next render.
|
|
107
|
+
*/
|
|
108
|
+
export declare function useUnsavedWorkGuard(hasUnsavedWork: boolean): {
|
|
109
|
+
release: () => void;
|
|
110
|
+
};
|
|
98
111
|
export {};
|
|
99
112
|
//# sourceMappingURL=local-upload-guard-provider.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"local-upload-guard-provider.d.ts","sourceRoot":"","sources":["../../src/react/local-upload-guard-provider.tsx"],"names":[],"mappings":"AAEA
|
|
1
|
+
{"version":3,"file":"local-upload-guard-provider.d.ts","sourceRoot":"","sources":["../../src/react/local-upload-guard-provider.tsx"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,EASL,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AAEf,MAAM,WAAW,wBAAwB;IACvC;;;;;;OAMG;IACH,kBAAkB,CAAC,EAAE,OAAO,CAAC;CAC9B;AAED,UAAU,4BAA4B;IACpC,gCAAgC;IAChC,iBAAiB,EAAE,MAAM,CAAC;IAC1B,yCAAyC;IACzC,gBAAgB,EAAE,MAAM,CAAC;IACzB,oGAAoG;IACpG,gBAAgB,EAAE,MAAM,CAAC;IACzB,cAAc,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IACrC,gBAAgB,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IACvC,mBAAmB,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IAC1C,qBAAqB,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IAC5C,qFAAqF;IACrF,uBAAuB,EAAE,MAAM,OAAO,CAAC;IACvC;;;;;;;;;OASG;IACH,iBAAiB,EAAE,CAAC,OAAO,CAAC,EAAE,wBAAwB,KAAK,OAAO,CAAC;CACpE;AAID,MAAM,WAAW,6BAA6B;IAC5C;;;;OAIG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,4FAA4F;IAC5F,wBAAwB,CAAC,EAAE,MAAM,CAAC;IAClC,wFAAwF;IACxF,yBAAyB,CAAC,EAAE,MAAM,CAAC;IACnC,4FAA4F;IAC5F,kCAAkC,CAAC,EAAE,MAAM,CAAC;IAC5C,QAAQ,EAAE,SAAS,CAAC;CACrB;AAoCD,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,6BAA6B,+BAoH5E;AAED;;;;GAIG;AACH,wBAAgB,0BAA0B,IAAI,4BAA4B,CAQzE;AAED;;;;;GAKG;AACH,wBAAgB,2BAA2B,IAAI,4BAA4B,GAAG,IAAI,CAEjF;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,mBAAmB,IAAI,4BAA4B,CAElE;AAED;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CAAC,cAAc,EAAE,OAAO,GAAG;IAAE,OAAO,EAAE,MAAM,IAAI,CAAA;CAAE,CAUpF"}
|
|
@@ -1,54 +1,77 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* The navigation and leave guard: warns before the user leaves while work that leaving would lose
|
|
5
|
+
* is registered — a LOCAL byte upload in flight, or an editor's unsaved input.
|
|
5
6
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* Uploads: phases `preparing` and `uploading` only. `finalizing` and backend `pendingMedia`
|
|
8
|
+
* processing are safe to leave (the bytes are already in Storage), so they are not registered.
|
|
9
|
+
* Unsaved work: an editor registers while it holds input its user has not saved, and unregisters
|
|
10
|
+
* when the input is saved, discarded, or the editor unmounts.
|
|
9
11
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* Storage and the only remaining work is the `startUpload` callable.
|
|
14
|
-
* - Does NOT cover backend `pendingMedia` status (`pending` /
|
|
15
|
-
* `processing`). Those run server-side and are safe to leave.
|
|
12
|
+
* While anything is registered the provider installs ONE `beforeunload` listener (tab close,
|
|
13
|
+
* reload, full-page navigation), and `confirmNavigation()` asks before an in-app navigation. It is
|
|
14
|
+
* the one leave guard: an editor never installs its own `beforeunload` handler (FRONTEND-207).
|
|
16
15
|
*
|
|
17
|
-
* Consumers register with a stable id
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* `finalizing`/success/error/cancel/unmount.
|
|
21
|
-
*
|
|
22
|
-
* Public hook: `useLocalUploadGuard()` returns
|
|
23
|
-
* `{ activeUploadCount, registerUpload, unregisterUpload }`. Throws if
|
|
24
|
-
* used outside a `LocalUploadGuardProvider`.
|
|
16
|
+
* Consumers register with a stable id per piece of work; registering a registered id, or
|
|
17
|
+
* unregistering an absent one, is a no-op. Uploads and unsaved work are counted separately, so the
|
|
18
|
+
* same id may be both.
|
|
25
19
|
*/
|
|
26
|
-
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react';
|
|
20
|
+
import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, } from 'react';
|
|
27
21
|
const LocalUploadGuardContext = createContext(undefined);
|
|
28
|
-
const DEFAULT_BEFORE_UNLOAD_MESSAGE = 'File upload in progress. Are you sure you want to leave and end the file upload?';
|
|
29
22
|
const DEFAULT_NAVIGATION_CONFIRM_MESSAGE = 'An upload is currently in progress. If you leave this page, the upload will be canceled. Continue?';
|
|
23
|
+
const DEFAULT_UNSAVED_WORK_CONFIRM_MESSAGE = 'You have unsaved changes. If you leave this page, they will be lost. Continue?';
|
|
24
|
+
const DEFAULT_UPLOAD_AND_UNSAVED_WORK_CONFIRM_MESSAGE = 'An upload is in progress and you have unsaved changes. If you leave this page, the upload will be canceled and your changes will be lost. Continue?';
|
|
25
|
+
const DEFAULT_UPLOAD_BEFORE_UNLOAD_MESSAGE = 'File upload in progress. Are you sure you want to leave and end the file upload?';
|
|
26
|
+
const DEFAULT_UNSAVED_WORK_BEFORE_UNLOAD_MESSAGE = 'You have unsaved changes. Are you sure you want to leave and lose them?';
|
|
27
|
+
function useRegistry(onRegister) {
|
|
28
|
+
const idsRef = useRef(new Set());
|
|
29
|
+
const [count, setCount] = useState(0);
|
|
30
|
+
const register = useCallback((id) => {
|
|
31
|
+
if (idsRef.current.has(id))
|
|
32
|
+
return;
|
|
33
|
+
onRegister();
|
|
34
|
+
idsRef.current.add(id);
|
|
35
|
+
setCount(idsRef.current.size);
|
|
36
|
+
}, [onRegister]);
|
|
37
|
+
const unregister = useCallback((id) => {
|
|
38
|
+
if (!idsRef.current.has(id))
|
|
39
|
+
return;
|
|
40
|
+
idsRef.current.delete(id);
|
|
41
|
+
setCount(idsRef.current.size);
|
|
42
|
+
}, []);
|
|
43
|
+
return { idsRef, count, register, unregister };
|
|
44
|
+
}
|
|
30
45
|
export function LocalUploadGuardProvider(props) {
|
|
31
|
-
const { children, beforeUnloadMessage =
|
|
32
|
-
const activeIdsRef = useRef(new Set());
|
|
33
|
-
const [activeUploadCount, setActiveUploadCount] = useState(0);
|
|
46
|
+
const { children, beforeUnloadMessage, navigationConfirmMessage = DEFAULT_NAVIGATION_CONFIRM_MESSAGE, unsavedWorkConfirmMessage = DEFAULT_UNSAVED_WORK_CONFIRM_MESSAGE, uploadAndUnsavedWorkConfirmMessage = DEFAULT_UPLOAD_AND_UNSAVED_WORK_CONFIRM_MESSAGE, } = props;
|
|
34
47
|
// One-shot: the user already confirmed the full-page navigation now unloading.
|
|
35
48
|
const bypassNextUnloadRef = useRef(false);
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
return;
|
|
39
|
-
// Work that starts after a confirmation was not covered by it.
|
|
49
|
+
// Also run when work registers: work that starts after a confirmation was not covered by it.
|
|
50
|
+
const clearBypass = useCallback(() => {
|
|
40
51
|
bypassNextUnloadRef.current = false;
|
|
41
|
-
activeIdsRef.current.add(id);
|
|
42
|
-
setActiveUploadCount(activeIdsRef.current.size);
|
|
43
|
-
}, []);
|
|
44
|
-
const unregisterUpload = useCallback((id) => {
|
|
45
|
-
if (!activeIdsRef.current.has(id))
|
|
46
|
-
return;
|
|
47
|
-
activeIdsRef.current.delete(id);
|
|
48
|
-
setActiveUploadCount(activeIdsRef.current.size);
|
|
49
52
|
}, []);
|
|
53
|
+
const uploads = useRegistry(clearBypass);
|
|
54
|
+
const unsavedWork = useRegistry(clearBypass);
|
|
55
|
+
const uploadIdsRef = uploads.idsRef;
|
|
56
|
+
const unsavedWorkIdsRef = unsavedWork.idsRef;
|
|
57
|
+
const guardedWorkCount = uploads.count + unsavedWork.count;
|
|
58
|
+
const hasGuardedWork = guardedWorkCount > 0;
|
|
59
|
+
// Read at the moment of asking, so a message always matches what is registered right then.
|
|
60
|
+
const confirmMessage = useCallback(() => {
|
|
61
|
+
const uploading = uploadIdsRef.current.size > 0;
|
|
62
|
+
const unsaved = unsavedWorkIdsRef.current.size > 0;
|
|
63
|
+
if (uploading && unsaved)
|
|
64
|
+
return uploadAndUnsavedWorkConfirmMessage;
|
|
65
|
+
return uploading ? navigationConfirmMessage : unsavedWorkConfirmMessage;
|
|
66
|
+
}, [
|
|
67
|
+
uploadIdsRef,
|
|
68
|
+
unsavedWorkIdsRef,
|
|
69
|
+
navigationConfirmMessage,
|
|
70
|
+
unsavedWorkConfirmMessage,
|
|
71
|
+
uploadAndUnsavedWorkConfirmMessage,
|
|
72
|
+
]);
|
|
50
73
|
useEffect(() => {
|
|
51
|
-
if (
|
|
74
|
+
if (!hasGuardedWork)
|
|
52
75
|
return;
|
|
53
76
|
const handleBeforeUnload = (event) => {
|
|
54
77
|
if (bypassNextUnloadRef.current) {
|
|
@@ -56,51 +79,65 @@ export function LocalUploadGuardProvider(props) {
|
|
|
56
79
|
return;
|
|
57
80
|
}
|
|
58
81
|
event.preventDefault();
|
|
59
|
-
// Modern browsers may ignore the custom string and show their own
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
82
|
+
// Modern browsers may ignore the custom string and show their own generic confirmation;
|
|
83
|
+
// what matters is that leaving is warned, not the exact text.
|
|
84
|
+
event.returnValue =
|
|
85
|
+
beforeUnloadMessage ??
|
|
86
|
+
(uploadIdsRef.current.size > 0
|
|
87
|
+
? DEFAULT_UPLOAD_BEFORE_UNLOAD_MESSAGE
|
|
88
|
+
: DEFAULT_UNSAVED_WORK_BEFORE_UNLOAD_MESSAGE);
|
|
63
89
|
return event.returnValue;
|
|
64
90
|
};
|
|
65
91
|
window.addEventListener('beforeunload', handleBeforeUnload);
|
|
66
92
|
return () => {
|
|
67
93
|
window.removeEventListener('beforeunload', handleBeforeUnload);
|
|
68
94
|
};
|
|
69
|
-
}, [
|
|
95
|
+
}, [hasGuardedWork, beforeUnloadMessage, uploadIdsRef]);
|
|
70
96
|
// A back/forward-cache return re-shows this page without reloading it: an
|
|
71
97
|
// armed bypass from the navigation that left must never outlive that navigation.
|
|
72
98
|
useEffect(() => {
|
|
73
|
-
const clearBypass = () => {
|
|
74
|
-
bypassNextUnloadRef.current = false;
|
|
75
|
-
};
|
|
76
99
|
window.addEventListener('pageshow', clearBypass);
|
|
77
100
|
return () => {
|
|
78
101
|
window.removeEventListener('pageshow', clearBypass);
|
|
79
102
|
};
|
|
80
|
-
}, []);
|
|
81
|
-
const shouldConfirmNavigation = useCallback(() =>
|
|
103
|
+
}, [clearBypass]);
|
|
104
|
+
const shouldConfirmNavigation = useCallback(() => uploadIdsRef.current.size > 0 || unsavedWorkIdsRef.current.size > 0, [uploadIdsRef, unsavedWorkIdsRef]);
|
|
82
105
|
const confirmNavigation = useCallback((options) => {
|
|
83
106
|
// SSR / non-browser safety: if `window` isn't available, default to allow.
|
|
84
107
|
if (typeof window === 'undefined')
|
|
85
108
|
return true;
|
|
86
|
-
const proceed =
|
|
109
|
+
const proceed = !shouldConfirmNavigation() || window.confirm(confirmMessage());
|
|
87
110
|
if (proceed && options?.fullPageNavigation)
|
|
88
111
|
bypassNextUnloadRef.current = true;
|
|
89
112
|
return proceed;
|
|
90
|
-
}, [
|
|
113
|
+
}, [shouldConfirmNavigation, confirmMessage]);
|
|
91
114
|
const value = useMemo(() => ({
|
|
92
|
-
activeUploadCount,
|
|
93
|
-
|
|
94
|
-
|
|
115
|
+
activeUploadCount: uploads.count,
|
|
116
|
+
unsavedWorkCount: unsavedWork.count,
|
|
117
|
+
guardedWorkCount,
|
|
118
|
+
registerUpload: uploads.register,
|
|
119
|
+
unregisterUpload: uploads.unregister,
|
|
120
|
+
registerUnsavedWork: unsavedWork.register,
|
|
121
|
+
unregisterUnsavedWork: unsavedWork.unregister,
|
|
95
122
|
shouldConfirmNavigation,
|
|
96
123
|
confirmNavigation,
|
|
97
|
-
}), [
|
|
124
|
+
}), [
|
|
125
|
+
uploads.count,
|
|
126
|
+
uploads.register,
|
|
127
|
+
uploads.unregister,
|
|
128
|
+
unsavedWork.count,
|
|
129
|
+
unsavedWork.register,
|
|
130
|
+
unsavedWork.unregister,
|
|
131
|
+
guardedWorkCount,
|
|
132
|
+
shouldConfirmNavigation,
|
|
133
|
+
confirmNavigation,
|
|
134
|
+
]);
|
|
98
135
|
return (_jsx(LocalUploadGuardContext.Provider, { value: value, children: children }));
|
|
99
136
|
}
|
|
100
137
|
/**
|
|
101
|
-
* Low-level accessor for the
|
|
102
|
-
*
|
|
103
|
-
*
|
|
138
|
+
* Low-level accessor for the guard context. Throws if used outside a
|
|
139
|
+
* `LocalUploadGuardProvider`. Most consumers should use `useLocalUploadGuard`
|
|
140
|
+
* (the public re-export below) instead.
|
|
104
141
|
*/
|
|
105
142
|
export function useLocalUploadGuardContext() {
|
|
106
143
|
const ctx = useContext(LocalUploadGuardContext);
|
|
@@ -119,9 +156,7 @@ export function useOptionalLocalUploadGuard() {
|
|
|
119
156
|
return useContext(LocalUploadGuardContext) ?? null;
|
|
120
157
|
}
|
|
121
158
|
/**
|
|
122
|
-
* Public hook for the
|
|
123
|
-
*
|
|
124
|
-
* Returns `{ activeUploadCount, registerUpload, unregisterUpload }`.
|
|
159
|
+
* Public hook for the navigation and leave guard.
|
|
125
160
|
*
|
|
126
161
|
* Typical usage in an upload flow:
|
|
127
162
|
*
|
|
@@ -132,8 +167,31 @@ export function useOptionalLocalUploadGuard() {
|
|
|
132
167
|
* return () => unregisterUpload(uploadId);
|
|
133
168
|
* }
|
|
134
169
|
* }, [phase, uploadId, registerUpload, unregisterUpload]);
|
|
170
|
+
*
|
|
171
|
+
* An editor holding input uses `useUnsavedWorkGuard(hasUnsavedWork)` instead.
|
|
135
172
|
*/
|
|
136
173
|
export function useLocalUploadGuard() {
|
|
137
174
|
return useLocalUploadGuardContext();
|
|
138
175
|
}
|
|
176
|
+
/**
|
|
177
|
+
* Registers the calling editor's unsaved input with the leave guard while `hasUnsavedWork` is
|
|
178
|
+
* true, and unregisters it when that turns false or the editor unmounts. While registered, closing
|
|
179
|
+
* the tab, reloading, a full-page navigation, and every guarded in-app navigation ask first.
|
|
180
|
+
* Throws outside a `LocalUploadGuardProvider`.
|
|
181
|
+
*
|
|
182
|
+
* Returns `release`, which unregisters at once: an editor that saves and then navigates in the
|
|
183
|
+
* same handler calls it after the save, because its dirty flag clears only on the next render.
|
|
184
|
+
*/
|
|
185
|
+
export function useUnsavedWorkGuard(hasUnsavedWork) {
|
|
186
|
+
const { registerUnsavedWork, unregisterUnsavedWork } = useLocalUploadGuardContext();
|
|
187
|
+
const id = useId();
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
if (!hasUnsavedWork)
|
|
190
|
+
return;
|
|
191
|
+
registerUnsavedWork(id);
|
|
192
|
+
return () => unregisterUnsavedWork(id);
|
|
193
|
+
}, [hasUnsavedWork, id, registerUnsavedWork, unregisterUnsavedWork]);
|
|
194
|
+
const release = useCallback(() => unregisterUnsavedWork(id), [id, unregisterUnsavedWork]);
|
|
195
|
+
return useMemo(() => ({ release }), [release]);
|
|
196
|
+
}
|
|
139
197
|
//# sourceMappingURL=local-upload-guard-provider.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"local-upload-guard-provider.js","sourceRoot":"","sources":["../../src/react/local-upload-guard-provider.tsx"],"names":[],"mappings":"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","sourcesContent":["'use client';\n\n/**\n * Local upload navigation guard provider.\n *\n * Tracks the count of in-progress LOCAL byte uploads (file → Firebase\n * Storage). While the count is > 0, installs a `beforeunload` listener\n * to warn the user before they close the tab or reload mid-upload.\n *\n * Scope:\n * - Covers phases `preparing` and `uploading` only.\n * - Does NOT cover `finalizing` — at that point bytes are already in\n * Storage and the only remaining work is the `startUpload` callable.\n * - Does NOT cover backend `pendingMedia` status (`pending` /\n * `processing`). Those run server-side and are safe to leave.\n *\n * Consumers register with a stable id (typically the local upload\n * session id or `pendingMedia` doc id once known) on entering\n * `preparing`/`uploading`, and unregister on transition to\n * `finalizing`/success/error/cancel/unmount.\n *\n * Public hook: `useLocalUploadGuard()` returns\n * `{ activeUploadCount, registerUpload, unregisterUpload }`. Throws if\n * used outside a `LocalUploadGuardProvider`.\n */\n\nimport {\n createContext,\n useCallback,\n useContext,\n useEffect,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from 'react';\n\nexport interface ConfirmNavigationOptions {\n /**\n * The caller leaves through a FULL-PAGE navigation (`location.assign` /\n * `location.replace`) once this returns true. The provider then lets that\n * navigation's next `beforeunload` pass without the browser's own\n * \"Leave site?\" prompt, because the user has just answered this one. Soft\n * (in-app router) navigations never pass it: they fire no `beforeunload`.\n */\n fullPageNavigation?: boolean;\n}\n\ninterface LocalUploadGuardContextValue {\n activeUploadCount: number;\n registerUpload: (id: string) => void;\n unregisterUpload: (id: string) => void;\n /** Returns true iff at least one upload is currently registered. Pure read. */\n shouldConfirmNavigation: () => boolean;\n /**\n * Shows a confirm() dialog with the configured message and returns the user's\n * choice. Safe to call when no upload is active — returns true without prompting.\n *\n * With `{ fullPageNavigation: true }` and a result of true (confirmed, or no\n * upload active), arms a ONE-SHOT bypass: the next `beforeunload` is not\n * prevented, so a confirmed full-page navigation does not prompt twice. A\n * declined confirm never arms it; the bypass is cleared when it is consumed, when\n * a new upload registers, and on `pageshow` (a back/forward-cache return).\n */\n confirmNavigation: (options?: ConfirmNavigationOptions) => boolean;\n}\n\nconst LocalUploadGuardContext = createContext<LocalUploadGuardContextValue | undefined>(undefined);\n\nexport interface LocalUploadGuardProviderProps {\n /**\n * Message shown by the browser when the user attempts to close the tab or\n * reload during an active upload. Modern browsers may ignore the custom\n * string and show a generic dialog.\n */\n beforeUnloadMessage?: string;\n /**\n * Message shown by `confirmNavigation()` when an upload is in progress and\n * the user attempts to navigate away within the app. Defaults to a\n * reasonable English string.\n */\n navigationConfirmMessage?: string;\n children: ReactNode;\n}\n\nconst DEFAULT_BEFORE_UNLOAD_MESSAGE =\n 'File upload in progress. Are you sure you want to leave and end the file upload?';\nconst DEFAULT_NAVIGATION_CONFIRM_MESSAGE =\n 'An upload is currently in progress. If you leave this page, the upload will be canceled. Continue?';\n\nexport function LocalUploadGuardProvider(props: LocalUploadGuardProviderProps) {\n const {\n children,\n beforeUnloadMessage = DEFAULT_BEFORE_UNLOAD_MESSAGE,\n navigationConfirmMessage = DEFAULT_NAVIGATION_CONFIRM_MESSAGE,\n } = props;\n const activeIdsRef = useRef<Set<string>>(new Set());\n const [activeUploadCount, setActiveUploadCount] = useState(0);\n // One-shot: the user already confirmed the full-page navigation now unloading.\n const bypassNextUnloadRef = useRef(false);\n\n const registerUpload = useCallback((id: string) => {\n if (activeIdsRef.current.has(id)) return;\n // Work that starts after a confirmation was not covered by it.\n bypassNextUnloadRef.current = false;\n activeIdsRef.current.add(id);\n setActiveUploadCount(activeIdsRef.current.size);\n }, []);\n\n const unregisterUpload = useCallback((id: string) => {\n if (!activeIdsRef.current.has(id)) return;\n activeIdsRef.current.delete(id);\n setActiveUploadCount(activeIdsRef.current.size);\n }, []);\n\n useEffect(() => {\n if (activeUploadCount <= 0) return;\n\n const handleBeforeUnload = (event: BeforeUnloadEvent) => {\n if (bypassNextUnloadRef.current) {\n bypassNextUnloadRef.current = false;\n return;\n }\n event.preventDefault();\n // Modern browsers may ignore the custom string and show their own\n // generic confirmation. The key behavior is that navigation is\n // blocked/warned, not the exact text.\n event.returnValue = beforeUnloadMessage;\n return event.returnValue;\n };\n\n window.addEventListener('beforeunload', handleBeforeUnload);\n return () => {\n window.removeEventListener('beforeunload', handleBeforeUnload);\n };\n }, [activeUploadCount, beforeUnloadMessage]);\n\n // A back/forward-cache return re-shows this page without reloading it: an\n // armed bypass from the navigation that left must never outlive that navigation.\n useEffect(() => {\n const clearBypass = () => {\n bypassNextUnloadRef.current = false;\n };\n window.addEventListener('pageshow', clearBypass);\n return () => {\n window.removeEventListener('pageshow', clearBypass);\n };\n }, []);\n\n const shouldConfirmNavigation = useCallback(\n () => activeIdsRef.current.size > 0,\n [],\n );\n\n const confirmNavigation = useCallback(\n (options?: ConfirmNavigationOptions): boolean => {\n // SSR / non-browser safety: if `window` isn't available, default to allow.\n if (typeof window === 'undefined') return true;\n const proceed =\n activeIdsRef.current.size === 0 || window.confirm(navigationConfirmMessage);\n if (proceed && options?.fullPageNavigation) bypassNextUnloadRef.current = true;\n return proceed;\n },\n [navigationConfirmMessage],\n );\n\n const value = useMemo<LocalUploadGuardContextValue>(\n () => ({\n activeUploadCount,\n registerUpload,\n unregisterUpload,\n shouldConfirmNavigation,\n confirmNavigation,\n }),\n [activeUploadCount, registerUpload, unregisterUpload, shouldConfirmNavigation, confirmNavigation],\n );\n\n return (\n <LocalUploadGuardContext.Provider value={value}>\n {children}\n </LocalUploadGuardContext.Provider>\n );\n}\n\n/**\n * Low-level accessor for the local-upload guard context. Throws if used\n * outside a `LocalUploadGuardProvider`. Most consumers should use\n * `useLocalUploadGuard` (the public re-export below) instead.\n */\nexport function useLocalUploadGuardContext(): LocalUploadGuardContextValue {\n const ctx = useContext(LocalUploadGuardContext);\n if (ctx === undefined) {\n throw new Error(\n 'useLocalUploadGuard must be used within LocalUploadGuardProvider',\n );\n }\n return ctx;\n}\n\n/**\n * Optional accessor: returns the guard context, or `null` when rendered\n * outside a `LocalUploadGuardProvider`. For package components (e.g. the\n * chat composer registering an in-flight send) that must degrade gracefully\n * in consumers that don't mount the provider, instead of throwing.\n */\nexport function useOptionalLocalUploadGuard(): LocalUploadGuardContextValue | null {\n return useContext(LocalUploadGuardContext) ?? null;\n}\n\n/**\n * Public hook for the local upload navigation guard.\n *\n * Returns `{ activeUploadCount, registerUpload, unregisterUpload }`.\n *\n * Typical usage in an upload flow:\n *\n * const { registerUpload, unregisterUpload } = useLocalUploadGuard();\n * useEffect(() => {\n * if (phase === 'preparing' || phase === 'uploading') {\n * registerUpload(uploadId);\n * return () => unregisterUpload(uploadId);\n * }\n * }, [phase, uploadId, registerUpload, unregisterUpload]);\n */\nexport function useLocalUploadGuard(): LocalUploadGuardContextValue {\n return useLocalUploadGuardContext();\n}\n"]}
|
|
1
|
+
{"version":3,"file":"local-upload-guard-provider.js","sourceRoot":"","sources":["../../src/react/local-upload-guard-provider.tsx"],"names":[],"mappings":"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","sourcesContent":["'use client';\n\n/**\n * The navigation and leave guard: warns before the user leaves while work that leaving would lose\n * is registered — a LOCAL byte upload in flight, or an editor's unsaved input.\n *\n * Uploads: phases `preparing` and `uploading` only. `finalizing` and backend `pendingMedia`\n * processing are safe to leave (the bytes are already in Storage), so they are not registered.\n * Unsaved work: an editor registers while it holds input its user has not saved, and unregisters\n * when the input is saved, discarded, or the editor unmounts.\n *\n * While anything is registered the provider installs ONE `beforeunload` listener (tab close,\n * reload, full-page navigation), and `confirmNavigation()` asks before an in-app navigation. It is\n * the one leave guard: an editor never installs its own `beforeunload` handler (FRONTEND-207).\n *\n * Consumers register with a stable id per piece of work; registering a registered id, or\n * unregistering an absent one, is a no-op. Uploads and unsaved work are counted separately, so the\n * same id may be both.\n */\n\nimport {\n createContext,\n useCallback,\n useContext,\n useEffect,\n useId,\n useMemo,\n useRef,\n useState,\n type ReactNode,\n} from 'react';\n\nexport interface ConfirmNavigationOptions {\n /**\n * The caller leaves through a FULL-PAGE navigation (`location.assign` /\n * `location.replace`) once this returns true. The provider then lets that\n * navigation's next `beforeunload` pass without the browser's own\n * \"Leave site?\" prompt, because the user has just answered this one. Soft\n * (in-app router) navigations never pass it: they fire no `beforeunload`.\n */\n fullPageNavigation?: boolean;\n}\n\ninterface LocalUploadGuardContextValue {\n /** Registered local uploads. */\n activeUploadCount: number;\n /** Registered pieces of unsaved work. */\n unsavedWorkCount: number;\n /** Everything the guard warns about: uploads plus unsaved work. Zero means leaving asks nothing. */\n guardedWorkCount: number;\n registerUpload: (id: string) => void;\n unregisterUpload: (id: string) => void;\n registerUnsavedWork: (id: string) => void;\n unregisterUnsavedWork: (id: string) => void;\n /** Returns true iff an upload or unsaved work is currently registered. Pure read. */\n shouldConfirmNavigation: () => boolean;\n /**\n * Shows a confirm() dialog with the message for what is registered and returns the user's\n * choice. Safe to call when nothing is registered — returns true without prompting.\n *\n * With `{ fullPageNavigation: true }` and a result of true (confirmed, or nothing registered),\n * arms a ONE-SHOT bypass: the next `beforeunload` is not prevented, so a confirmed full-page\n * navigation does not prompt twice. A declined confirm never arms it; the bypass is cleared when\n * it is consumed, when new work (an upload or unsaved work) registers, and on `pageshow` (a\n * back/forward-cache return).\n */\n confirmNavigation: (options?: ConfirmNavigationOptions) => boolean;\n}\n\nconst LocalUploadGuardContext = createContext<LocalUploadGuardContextValue | undefined>(undefined);\n\nexport interface LocalUploadGuardProviderProps {\n /**\n * Message the browser is given when the user closes the tab, reloads, or leaves the page while\n * work is registered. Modern browsers show their own generic dialog and ignore it; when omitted,\n * the default for what is registered is used.\n */\n beforeUnloadMessage?: string;\n /** `confirmNavigation()`'s message while an upload is in progress (and no unsaved work). */\n navigationConfirmMessage?: string;\n /** `confirmNavigation()`'s message while unsaved work is registered (and no upload). */\n unsavedWorkConfirmMessage?: string;\n /** `confirmNavigation()`'s message while both an upload and unsaved work are registered. */\n uploadAndUnsavedWorkConfirmMessage?: string;\n children: ReactNode;\n}\n\nconst DEFAULT_NAVIGATION_CONFIRM_MESSAGE =\n 'An upload is currently in progress. If you leave this page, the upload will be canceled. Continue?';\nconst DEFAULT_UNSAVED_WORK_CONFIRM_MESSAGE =\n 'You have unsaved changes. If you leave this page, they will be lost. Continue?';\nconst DEFAULT_UPLOAD_AND_UNSAVED_WORK_CONFIRM_MESSAGE =\n 'An upload is in progress and you have unsaved changes. If you leave this page, the upload will be canceled and your changes will be lost. Continue?';\nconst DEFAULT_UPLOAD_BEFORE_UNLOAD_MESSAGE =\n 'File upload in progress. Are you sure you want to leave and end the file upload?';\nconst DEFAULT_UNSAVED_WORK_BEFORE_UNLOAD_MESSAGE =\n 'You have unsaved changes. Are you sure you want to leave and lose them?';\n\nfunction useRegistry(onRegister: () => void) {\n const idsRef = useRef<Set<string>>(new Set());\n const [count, setCount] = useState(0);\n\n const register = useCallback(\n (id: string) => {\n if (idsRef.current.has(id)) return;\n onRegister();\n idsRef.current.add(id);\n setCount(idsRef.current.size);\n },\n [onRegister],\n );\n\n const unregister = useCallback((id: string) => {\n if (!idsRef.current.has(id)) return;\n idsRef.current.delete(id);\n setCount(idsRef.current.size);\n }, []);\n\n return { idsRef, count, register, unregister };\n}\n\nexport function LocalUploadGuardProvider(props: LocalUploadGuardProviderProps) {\n const {\n children,\n beforeUnloadMessage,\n navigationConfirmMessage = DEFAULT_NAVIGATION_CONFIRM_MESSAGE,\n unsavedWorkConfirmMessage = DEFAULT_UNSAVED_WORK_CONFIRM_MESSAGE,\n uploadAndUnsavedWorkConfirmMessage = DEFAULT_UPLOAD_AND_UNSAVED_WORK_CONFIRM_MESSAGE,\n } = props;\n // One-shot: the user already confirmed the full-page navigation now unloading.\n const bypassNextUnloadRef = useRef(false);\n // Also run when work registers: work that starts after a confirmation was not covered by it.\n const clearBypass = useCallback(() => {\n bypassNextUnloadRef.current = false;\n }, []);\n\n const uploads = useRegistry(clearBypass);\n const unsavedWork = useRegistry(clearBypass);\n const uploadIdsRef = uploads.idsRef;\n const unsavedWorkIdsRef = unsavedWork.idsRef;\n const guardedWorkCount = uploads.count + unsavedWork.count;\n const hasGuardedWork = guardedWorkCount > 0;\n\n // Read at the moment of asking, so a message always matches what is registered right then.\n const confirmMessage = useCallback((): string => {\n const uploading = uploadIdsRef.current.size > 0;\n const unsaved = unsavedWorkIdsRef.current.size > 0;\n if (uploading && unsaved) return uploadAndUnsavedWorkConfirmMessage;\n return uploading ? navigationConfirmMessage : unsavedWorkConfirmMessage;\n }, [\n uploadIdsRef,\n unsavedWorkIdsRef,\n navigationConfirmMessage,\n unsavedWorkConfirmMessage,\n uploadAndUnsavedWorkConfirmMessage,\n ]);\n\n useEffect(() => {\n if (!hasGuardedWork) return;\n\n const handleBeforeUnload = (event: BeforeUnloadEvent) => {\n if (bypassNextUnloadRef.current) {\n bypassNextUnloadRef.current = false;\n return;\n }\n event.preventDefault();\n // Modern browsers may ignore the custom string and show their own generic confirmation;\n // what matters is that leaving is warned, not the exact text.\n event.returnValue =\n beforeUnloadMessage ??\n (uploadIdsRef.current.size > 0\n ? DEFAULT_UPLOAD_BEFORE_UNLOAD_MESSAGE\n : DEFAULT_UNSAVED_WORK_BEFORE_UNLOAD_MESSAGE);\n return event.returnValue;\n };\n\n window.addEventListener('beforeunload', handleBeforeUnload);\n return () => {\n window.removeEventListener('beforeunload', handleBeforeUnload);\n };\n }, [hasGuardedWork, beforeUnloadMessage, uploadIdsRef]);\n\n // A back/forward-cache return re-shows this page without reloading it: an\n // armed bypass from the navigation that left must never outlive that navigation.\n useEffect(() => {\n window.addEventListener('pageshow', clearBypass);\n return () => {\n window.removeEventListener('pageshow', clearBypass);\n };\n }, [clearBypass]);\n\n const shouldConfirmNavigation = useCallback(\n () => uploadIdsRef.current.size > 0 || unsavedWorkIdsRef.current.size > 0,\n [uploadIdsRef, unsavedWorkIdsRef],\n );\n\n const confirmNavigation = useCallback(\n (options?: ConfirmNavigationOptions): boolean => {\n // SSR / non-browser safety: if `window` isn't available, default to allow.\n if (typeof window === 'undefined') return true;\n const proceed = !shouldConfirmNavigation() || window.confirm(confirmMessage());\n if (proceed && options?.fullPageNavigation) bypassNextUnloadRef.current = true;\n return proceed;\n },\n [shouldConfirmNavigation, confirmMessage],\n );\n\n const value = useMemo<LocalUploadGuardContextValue>(\n () => ({\n activeUploadCount: uploads.count,\n unsavedWorkCount: unsavedWork.count,\n guardedWorkCount,\n registerUpload: uploads.register,\n unregisterUpload: uploads.unregister,\n registerUnsavedWork: unsavedWork.register,\n unregisterUnsavedWork: unsavedWork.unregister,\n shouldConfirmNavigation,\n confirmNavigation,\n }),\n [\n uploads.count,\n uploads.register,\n uploads.unregister,\n unsavedWork.count,\n unsavedWork.register,\n unsavedWork.unregister,\n guardedWorkCount,\n shouldConfirmNavigation,\n confirmNavigation,\n ],\n );\n\n return (\n <LocalUploadGuardContext.Provider value={value}>\n {children}\n </LocalUploadGuardContext.Provider>\n );\n}\n\n/**\n * Low-level accessor for the guard context. Throws if used outside a\n * `LocalUploadGuardProvider`. Most consumers should use `useLocalUploadGuard`\n * (the public re-export below) instead.\n */\nexport function useLocalUploadGuardContext(): LocalUploadGuardContextValue {\n const ctx = useContext(LocalUploadGuardContext);\n if (ctx === undefined) {\n throw new Error(\n 'useLocalUploadGuard must be used within LocalUploadGuardProvider',\n );\n }\n return ctx;\n}\n\n/**\n * Optional accessor: returns the guard context, or `null` when rendered\n * outside a `LocalUploadGuardProvider`. For package components (e.g. the\n * chat composer registering an in-flight send) that must degrade gracefully\n * in consumers that don't mount the provider, instead of throwing.\n */\nexport function useOptionalLocalUploadGuard(): LocalUploadGuardContextValue | null {\n return useContext(LocalUploadGuardContext) ?? null;\n}\n\n/**\n * Public hook for the navigation and leave guard.\n *\n * Typical usage in an upload flow:\n *\n * const { registerUpload, unregisterUpload } = useLocalUploadGuard();\n * useEffect(() => {\n * if (phase === 'preparing' || phase === 'uploading') {\n * registerUpload(uploadId);\n * return () => unregisterUpload(uploadId);\n * }\n * }, [phase, uploadId, registerUpload, unregisterUpload]);\n *\n * An editor holding input uses `useUnsavedWorkGuard(hasUnsavedWork)` instead.\n */\nexport function useLocalUploadGuard(): LocalUploadGuardContextValue {\n return useLocalUploadGuardContext();\n}\n\n/**\n * Registers the calling editor's unsaved input with the leave guard while `hasUnsavedWork` is\n * true, and unregisters it when that turns false or the editor unmounts. While registered, closing\n * the tab, reloading, a full-page navigation, and every guarded in-app navigation ask first.\n * Throws outside a `LocalUploadGuardProvider`.\n *\n * Returns `release`, which unregisters at once: an editor that saves and then navigates in the\n * same handler calls it after the save, because its dirty flag clears only on the next render.\n */\nexport function useUnsavedWorkGuard(hasUnsavedWork: boolean): { release: () => void } {\n const { registerUnsavedWork, unregisterUnsavedWork } = useLocalUploadGuardContext();\n const id = useId();\n useEffect(() => {\n if (!hasUnsavedWork) return;\n registerUnsavedWork(id);\n return () => unregisterUnsavedWork(id);\n }, [hasUnsavedWork, id, registerUnsavedWork, unregisterUnsavedWork]);\n const release = useCallback(() => unregisterUnsavedWork(id), [id, unregisterUnsavedWork]);\n return useMemo(() => ({ release }), [release]);\n}\n"]}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { type ConfirmNavigationOptions } from './local-upload-guard-provider.js';
|
|
2
2
|
/**
|
|
3
3
|
* Returns a callable that wraps a navigation action. Calling the returned
|
|
4
|
-
* function with `performNavigation` either invokes it immediately (when
|
|
5
|
-
*
|
|
4
|
+
* function with `performNavigation` either invokes it immediately (when
|
|
5
|
+
* nothing is registered) or shows a confirmation prompt; if the user confirms,
|
|
6
6
|
* `performNavigation` is invoked. `options` passes through to
|
|
7
7
|
* `confirmNavigation()`.
|
|
8
8
|
*/
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
/**
|
|
3
|
-
* Wraps a navigation action so it prompts the user
|
|
4
|
-
*
|
|
5
|
-
* call and either runs it immediately (
|
|
3
|
+
* Wraps a navigation action so it prompts the user while an upload or unsaved
|
|
4
|
+
* work is registered. Returns a function that takes the consumer's actual navigation
|
|
5
|
+
* call and either runs it immediately (nothing registered) or shows a confirm()
|
|
6
6
|
* dialog first.
|
|
7
7
|
*
|
|
8
8
|
* Designed to be framework-agnostic — the consumer supplies the navigator
|
|
@@ -24,8 +24,8 @@ import { useCallback } from 'react';
|
|
|
24
24
|
import { useLocalUploadGuard } from './local-upload-guard-provider.js';
|
|
25
25
|
/**
|
|
26
26
|
* Returns a callable that wraps a navigation action. Calling the returned
|
|
27
|
-
* function with `performNavigation` either invokes it immediately (when
|
|
28
|
-
*
|
|
27
|
+
* function with `performNavigation` either invokes it immediately (when
|
|
28
|
+
* nothing is registered) or shows a confirmation prompt; if the user confirms,
|
|
29
29
|
* `performNavigation` is invoked. `options` passes through to
|
|
30
30
|
* `confirmNavigation()`.
|
|
31
31
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-guarded-navigation.js","sourceRoot":"","sources":["../../src/react/use-guarded-navigation.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,EAAE,WAAW,EAAE,MAAM,OAAO,CAAC;AACpC,OAAO,EAAE,mBAAmB,EAAiC,MAAM,kCAAkC,CAAC;AAEtG;;;;;;GAMG;AACH,MAAM,UAAU,oBAAoB;IAIlC,MAAM,EAAE,iBAAiB,EAAE,GAAG,mBAAmB,EAAE,CAAC;IACpD,OAAO,WAAW,CAChB,CAAC,iBAA6B,EAAE,OAAkC,EAAE,EAAE;QACpE,IAAI,iBAAiB,CAAC,OAAO,CAAC,EAAE,CAAC;YAC/B,iBAAiB,EAAE,CAAC;QACtB,CAAC;IACH,CAAC,EACD,CAAC,iBAAiB,CAAC,CACpB,CAAC;AACJ,CAAC","sourcesContent":["'use client';\n\n/**\n * Wraps a navigation action so it prompts the user
|
|
1
|
+
{"version":3,"file":"use-guarded-navigation.js","sourceRoot":"","sources":["../../src/react/use-guarded-navigation.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,EAAE,WAAW,EAAE,MAAM,OAAO,CAAC;AACpC,OAAO,EAAE,mBAAmB,EAAiC,MAAM,kCAAkC,CAAC;AAEtG;;;;;;GAMG;AACH,MAAM,UAAU,oBAAoB;IAIlC,MAAM,EAAE,iBAAiB,EAAE,GAAG,mBAAmB,EAAE,CAAC;IACpD,OAAO,WAAW,CAChB,CAAC,iBAA6B,EAAE,OAAkC,EAAE,EAAE;QACpE,IAAI,iBAAiB,CAAC,OAAO,CAAC,EAAE,CAAC;YAC/B,iBAAiB,EAAE,CAAC;QACtB,CAAC;IACH,CAAC,EACD,CAAC,iBAAiB,CAAC,CACpB,CAAC;AACJ,CAAC","sourcesContent":["'use client';\n\n/**\n * Wraps a navigation action so it prompts the user while an upload or unsaved\n * work is registered. Returns a function that takes the consumer's actual navigation\n * call and either runs it immediately (nothing registered) or shows a confirm()\n * dialog first.\n *\n * Designed to be framework-agnostic — the consumer supplies the navigator\n * (Next.js router.push, React Router navigate, plain location.assign, etc.).\n * A FULL-PAGE navigator (`location.assign` / `location.replace`) passes\n * `{ fullPageNavigation: true }`, so the unload that follows a confirmed\n * navigation does not raise the browser's own \"Leave site?\" as a second prompt\n * (see `ConfirmNavigationOptions`). Router navigations pass nothing.\n *\n * Typical use inside a button:\n *\n * const guardedNav = useGuardedNavigation();\n * <Button onClick={() => guardedNav(() => router.push('/somewhere'))}>Go</Button>\n * <Button onClick={() => guardedNav(() => location.assign(url), { fullPageNavigation: true })}>Leave</Button>\n *\n * For links, use the `<GuardedLink>` render-prop wrapper.\n */\n\nimport { useCallback } from 'react';\nimport { useLocalUploadGuard, type ConfirmNavigationOptions } from './local-upload-guard-provider.js';\n\n/**\n * Returns a callable that wraps a navigation action. Calling the returned\n * function with `performNavigation` either invokes it immediately (when\n * nothing is registered) or shows a confirmation prompt; if the user confirms,\n * `performNavigation` is invoked. `options` passes through to\n * `confirmNavigation()`.\n */\nexport function useGuardedNavigation(): (\n performNavigation: () => void,\n options?: ConfirmNavigationOptions,\n) => void {\n const { confirmNavigation } = useLocalUploadGuard();\n return useCallback(\n (performNavigation: () => void, options?: ConfirmNavigationOptions) => {\n if (confirmNavigation(options)) {\n performNavigation();\n }\n },\n [confirmNavigation],\n );\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ttt-productions/upload-ui",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.13",
|
|
4
4
|
"description": "Upload UI primitives: local upload guard provider, useGuardedUpload helper, DeferredUploadFormShell for forms that defer Firebase Storage upload until form submit.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -40,10 +40,10 @@
|
|
|
40
40
|
"prepublishOnly": "npm run clean && npm run build"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@ttt-productions/file-input": "^0.11.
|
|
44
|
-
"@ttt-productions/media-schemas": "^0.14.
|
|
45
|
-
"@ttt-productions/ui-core": "^0.11.
|
|
46
|
-
"@ttt-productions/upload-core": "^0.7.
|
|
43
|
+
"@ttt-productions/file-input": "^0.11.13",
|
|
44
|
+
"@ttt-productions/media-schemas": "^0.14.8",
|
|
45
|
+
"@ttt-productions/ui-core": "^0.11.21",
|
|
46
|
+
"@ttt-productions/upload-core": "^0.7.5"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
49
|
"@tanstack/react-query": ">=5.0.0",
|