@idevconn/use-draft 0.2.0 → 0.2.2

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/index.cjs CHANGED
@@ -68,8 +68,18 @@ function useDraft(isDirty) {
68
68
  }, [isDirty]);
69
69
  const { status, proceed, reset } = (0, import_react_router.useBlocker)({
70
70
  shouldBlockFn,
71
+ enableBeforeUnload: shouldBlockFn,
71
72
  withResolver: true
72
73
  });
74
+ const blockerStateRef = (0, import_react.useRef)({ status, reset });
75
+ blockerStateRef.current = { status, reset };
76
+ (0, import_react.useEffect)(() => {
77
+ return () => {
78
+ if (blockerStateRef.current.status === "blocked") {
79
+ blockerStateRef.current.reset?.();
80
+ }
81
+ };
82
+ }, []);
73
83
  (0, import_react.useEffect)(() => {
74
84
  function handleBeforeUnload(e) {
75
85
  if (useDraftStore.getState().isDirty) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/use-draft.ts","../src/store.ts"],"sourcesContent":["export { useDraft } from \"./use-draft\";\nexport type { IsDirtyInput, UseDraftReturn } from \"./use-draft\";\nexport { useDraftStore } from \"./store\";\nexport type { DraftState } from \"./store\";\n","import { useCallback, useEffect, useId } from \"react\";\nimport { useBlocker } from \"@tanstack/react-router\";\nimport { useDraftStore } from \"./store\";\n\nexport interface UseDraftReturn {\n /** True when navigation is blocked and the consumer should render its confirm dialog. */\n showDialog: boolean;\n /** Proceed with the originally-attempted navigation. */\n confirmLeave: () => void;\n /** Stay on the current page, dismiss the dialog. */\n cancelLeave: () => void;\n}\n\nexport type IsDirtyInput = boolean | (() => boolean);\n\n/**\n * Track unsaved-changes state for the current form and block navigation\n * while it's dirty. The hook:\n *\n * 1. Reports this form's dirty state into the global draft store so other\n * components (e.g. an upload indicator outside the form tree) can read\n * the aggregate via `useDraftStore`.\n * 2. Hooks TanStack Router's `useBlocker` (with `withResolver: true`) so\n * in-app navigation pauses on a status === \"blocked\" condition,\n * letting the caller render a confirm dialog and call confirmLeave /\n * cancelLeave.\n * 3. Adds a `beforeunload` listener so browser refresh / tab-close\n * surfaces the native \"Leave site?\" dialog when ANY form is dirty.\n *\n * Pass a getter function (`() => isDirty`) rather than a boolean when the\n * dirty state changes inside the same event handler that triggers\n * navigation — `useBlocker` evaluates the predicate lazily, so a getter\n * reading from a `useRef` sees the synchronously-set value before React\n * has a chance to re-render.\n */\nexport function useDraft(isDirty: IsDirtyInput): UseDraftReturn {\n const formId = useId();\n const markDirty = useDraftStore((s) => s.markDirty);\n const markClean = useDraftStore((s) => s.markClean);\n\n const isDirtyNow = typeof isDirty === \"function\" ? isDirty() : isDirty;\n\n useEffect(() => {\n if (isDirtyNow) {\n markDirty(formId);\n } else {\n markClean(formId);\n }\n return () => markClean(formId);\n }, [isDirtyNow, formId, markDirty, markClean]);\n\n const shouldBlockFn = useCallback(() => {\n return typeof isDirty === \"function\" ? isDirty() : isDirty;\n }, [isDirty]);\n\n const { status, proceed, reset } = useBlocker({\n shouldBlockFn,\n withResolver: true,\n });\n\n useEffect(() => {\n function handleBeforeUnload(e: BeforeUnloadEvent) {\n if (useDraftStore.getState().isDirty) {\n e.preventDefault();\n }\n }\n window.addEventListener(\"beforeunload\", handleBeforeUnload);\n return () => window.removeEventListener(\"beforeunload\", handleBeforeUnload);\n }, []);\n\n return {\n showDialog: status === \"blocked\",\n confirmLeave: () => proceed?.(),\n cancelLeave: () => reset?.(),\n };\n}\n","import { create } from \"zustand\";\n\nexport interface DraftState {\n /** Set of form IDs currently flagged as dirty. */\n dirtyForms: Set<string>;\n /** True when at least one form is dirty. */\n isDirty: boolean;\n /** Flag the given form ID as dirty. Idempotent. */\n markDirty: (formId: string) => void;\n /** Clear the given form ID. Idempotent. */\n markClean: (formId: string) => void;\n /** Drop every dirty flag. Useful in tests / global logout. */\n reset: () => void;\n}\n\n/**\n * Global store of dirty form IDs. Any component can subscribe with\n * `useDraftStore((s) => s.isDirty)` to react to the aggregate state — e.g.\n * an upload-in-progress indicator that lives outside the form tree.\n */\nexport const useDraftStore = create<DraftState>()((set) => ({\n dirtyForms: new Set<string>(),\n isDirty: false,\n markDirty: (formId) =>\n set((state) => {\n if (state.dirtyForms.has(formId)) return state;\n const next = new Set(state.dirtyForms);\n next.add(formId);\n return { dirtyForms: next, isDirty: next.size > 0 };\n }),\n markClean: (formId) =>\n set((state) => {\n if (!state.dirtyForms.has(formId)) return state;\n const next = new Set(state.dirtyForms);\n next.delete(formId);\n return { dirtyForms: next, isDirty: next.size > 0 };\n }),\n reset: () => set({ dirtyForms: new Set<string>(), isDirty: false }),\n}));\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACAA,mBAA8C;AAC9C,0BAA2B;;;ACD3B,qBAAuB;AAoBhB,IAAM,oBAAgB,uBAAmB,EAAE,CAAC,SAAS;AAAA,EAC1D,YAAY,oBAAI,IAAY;AAAA,EAC5B,SAAS;AAAA,EACT,WAAW,CAAC,WACV,IAAI,CAAC,UAAU;AACb,QAAI,MAAM,WAAW,IAAI,MAAM,EAAG,QAAO;AACzC,UAAM,OAAO,IAAI,IAAI,MAAM,UAAU;AACrC,SAAK,IAAI,MAAM;AACf,WAAO,EAAE,YAAY,MAAM,SAAS,KAAK,OAAO,EAAE;AAAA,EACpD,CAAC;AAAA,EACH,WAAW,CAAC,WACV,IAAI,CAAC,UAAU;AACb,QAAI,CAAC,MAAM,WAAW,IAAI,MAAM,EAAG,QAAO;AAC1C,UAAM,OAAO,IAAI,IAAI,MAAM,UAAU;AACrC,SAAK,OAAO,MAAM;AAClB,WAAO,EAAE,YAAY,MAAM,SAAS,KAAK,OAAO,EAAE;AAAA,EACpD,CAAC;AAAA,EACH,OAAO,MAAM,IAAI,EAAE,YAAY,oBAAI,IAAY,GAAG,SAAS,MAAM,CAAC;AACpE,EAAE;;;ADHK,SAAS,SAAS,SAAuC;AAC9D,QAAM,aAAS,oBAAM;AACrB,QAAM,YAAY,cAAc,CAAC,MAAM,EAAE,SAAS;AAClD,QAAM,YAAY,cAAc,CAAC,MAAM,EAAE,SAAS;AAElD,QAAM,aAAa,OAAO,YAAY,aAAa,QAAQ,IAAI;AAE/D,8BAAU,MAAM;AACd,QAAI,YAAY;AACd,gBAAU,MAAM;AAAA,IAClB,OAAO;AACL,gBAAU,MAAM;AAAA,IAClB;AACA,WAAO,MAAM,UAAU,MAAM;AAAA,EAC/B,GAAG,CAAC,YAAY,QAAQ,WAAW,SAAS,CAAC;AAE7C,QAAM,oBAAgB,0BAAY,MAAM;AACtC,WAAO,OAAO,YAAY,aAAa,QAAQ,IAAI;AAAA,EACrD,GAAG,CAAC,OAAO,CAAC;AAEZ,QAAM,EAAE,QAAQ,SAAS,MAAM,QAAI,gCAAW;AAAA,IAC5C;AAAA,IACA,cAAc;AAAA,EAChB,CAAC;AAED,8BAAU,MAAM;AACd,aAAS,mBAAmB,GAAsB;AAChD,UAAI,cAAc,SAAS,EAAE,SAAS;AACpC,UAAE,eAAe;AAAA,MACnB;AAAA,IACF;AACA,WAAO,iBAAiB,gBAAgB,kBAAkB;AAC1D,WAAO,MAAM,OAAO,oBAAoB,gBAAgB,kBAAkB;AAAA,EAC5E,GAAG,CAAC,CAAC;AAEL,SAAO;AAAA,IACL,YAAY,WAAW;AAAA,IACvB,cAAc,MAAM,UAAU;AAAA,IAC9B,aAAa,MAAM,QAAQ;AAAA,EAC7B;AACF;","names":[]}
1
+ {"version":3,"sources":["../src/index.ts","../src/use-draft.ts","../src/store.ts"],"sourcesContent":["export { useDraft } from \"./use-draft\";\nexport type { IsDirtyInput, UseDraftReturn } from \"./use-draft\";\nexport { useDraftStore } from \"./store\";\nexport type { DraftState } from \"./store\";\n","import { useCallback, useEffect, useId, useRef } from \"react\";\nimport { useBlocker } from \"@tanstack/react-router\";\nimport { useDraftStore } from \"./store\";\n\nexport interface UseDraftReturn {\n /** True when navigation is blocked and the consumer should render its confirm dialog. */\n showDialog: boolean;\n /** Proceed with the originally-attempted navigation. */\n confirmLeave: () => void;\n /** Stay on the current page, dismiss the dialog. */\n cancelLeave: () => void;\n}\n\nexport type IsDirtyInput = boolean | (() => boolean);\n\n/**\n * Track unsaved-changes state for the current form and block navigation\n * while it's dirty. The hook:\n *\n * 1. Reports this form's dirty state into the global draft store so other\n * components (e.g. an upload indicator outside the form tree) can read\n * the aggregate via `useDraftStore`.\n * 2. Hooks TanStack Router's `useBlocker` (with `withResolver: true`) so\n * in-app navigation pauses on a status === \"blocked\" condition,\n * letting the caller render a confirm dialog and call confirmLeave /\n * cancelLeave.\n * 3. Adds a `beforeunload` listener so browser refresh / tab-close\n * surfaces the native \"Leave site?\" dialog when ANY form is dirty.\n *\n * Pass a getter function (`() => isDirty`) rather than a boolean when the\n * dirty state changes inside the same event handler that triggers\n * navigation — `useBlocker` evaluates the predicate lazily, so a getter\n * reading from a `useRef` sees the synchronously-set value before React\n * has a chance to re-render.\n */\nexport function useDraft(isDirty: IsDirtyInput): UseDraftReturn {\n const formId = useId();\n const markDirty = useDraftStore((s) => s.markDirty);\n const markClean = useDraftStore((s) => s.markClean);\n\n const isDirtyNow = typeof isDirty === \"function\" ? isDirty() : isDirty;\n\n useEffect(() => {\n if (isDirtyNow) {\n markDirty(formId);\n } else {\n markClean(formId);\n }\n return () => markClean(formId);\n }, [isDirtyNow, formId, markDirty, markClean]);\n\n const shouldBlockFn = useCallback(() => {\n return typeof isDirty === \"function\" ? isDirty() : isDirty;\n }, [isDirty]);\n\n const { status, proceed, reset } = useBlocker({\n shouldBlockFn,\n enableBeforeUnload: shouldBlockFn,\n withResolver: true,\n });\n\n // If this component unmounts while a navigation is blocked (status ===\n // \"blocked\"), the underlying router promise never resolves unless\n // something calls proceed()/reset() — a consumer that forgets to render\n // the confirm dialog (or unmounts before the user answers it) would\n // otherwise leave every future navigation attempt hanging forever with\n // no dialog and no way to recover short of a full page reload. Default\n // to reset() (cancel the navigation) on unmount so the app never gets\n // permanently stuck; the user can simply retry the navigation.\n const blockerStateRef = useRef({ status, reset });\n blockerStateRef.current = { status, reset };\n useEffect(() => {\n return () => {\n if (blockerStateRef.current.status === \"blocked\") {\n blockerStateRef.current.reset?.();\n }\n };\n }, []);\n\n useEffect(() => {\n function handleBeforeUnload(e: BeforeUnloadEvent) {\n if (useDraftStore.getState().isDirty) {\n e.preventDefault();\n }\n }\n window.addEventListener(\"beforeunload\", handleBeforeUnload);\n return () => window.removeEventListener(\"beforeunload\", handleBeforeUnload);\n }, []);\n\n return {\n showDialog: status === \"blocked\",\n confirmLeave: () => proceed?.(),\n cancelLeave: () => reset?.(),\n };\n}\n","import { create } from \"zustand\";\n\nexport interface DraftState {\n /** Set of form IDs currently flagged as dirty. */\n dirtyForms: Set<string>;\n /** True when at least one form is dirty. */\n isDirty: boolean;\n /** Flag the given form ID as dirty. Idempotent. */\n markDirty: (formId: string) => void;\n /** Clear the given form ID. Idempotent. */\n markClean: (formId: string) => void;\n /** Drop every dirty flag. Useful in tests / global logout. */\n reset: () => void;\n}\n\n/**\n * Global store of dirty form IDs. Any component can subscribe with\n * `useDraftStore((s) => s.isDirty)` to react to the aggregate state — e.g.\n * an upload-in-progress indicator that lives outside the form tree.\n */\nexport const useDraftStore = create<DraftState>()((set) => ({\n dirtyForms: new Set<string>(),\n isDirty: false,\n markDirty: (formId) =>\n set((state) => {\n if (state.dirtyForms.has(formId)) return state;\n const next = new Set(state.dirtyForms);\n next.add(formId);\n return { dirtyForms: next, isDirty: next.size > 0 };\n }),\n markClean: (formId) =>\n set((state) => {\n if (!state.dirtyForms.has(formId)) return state;\n const next = new Set(state.dirtyForms);\n next.delete(formId);\n return { dirtyForms: next, isDirty: next.size > 0 };\n }),\n reset: () => set({ dirtyForms: new Set<string>(), isDirty: false }),\n}));\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACAA,mBAAsD;AACtD,0BAA2B;;;ACD3B,qBAAuB;AAoBhB,IAAM,oBAAgB,uBAAmB,EAAE,CAAC,SAAS;AAAA,EAC1D,YAAY,oBAAI,IAAY;AAAA,EAC5B,SAAS;AAAA,EACT,WAAW,CAAC,WACV,IAAI,CAAC,UAAU;AACb,QAAI,MAAM,WAAW,IAAI,MAAM,EAAG,QAAO;AACzC,UAAM,OAAO,IAAI,IAAI,MAAM,UAAU;AACrC,SAAK,IAAI,MAAM;AACf,WAAO,EAAE,YAAY,MAAM,SAAS,KAAK,OAAO,EAAE;AAAA,EACpD,CAAC;AAAA,EACH,WAAW,CAAC,WACV,IAAI,CAAC,UAAU;AACb,QAAI,CAAC,MAAM,WAAW,IAAI,MAAM,EAAG,QAAO;AAC1C,UAAM,OAAO,IAAI,IAAI,MAAM,UAAU;AACrC,SAAK,OAAO,MAAM;AAClB,WAAO,EAAE,YAAY,MAAM,SAAS,KAAK,OAAO,EAAE;AAAA,EACpD,CAAC;AAAA,EACH,OAAO,MAAM,IAAI,EAAE,YAAY,oBAAI,IAAY,GAAG,SAAS,MAAM,CAAC;AACpE,EAAE;;;ADHK,SAAS,SAAS,SAAuC;AAC9D,QAAM,aAAS,oBAAM;AACrB,QAAM,YAAY,cAAc,CAAC,MAAM,EAAE,SAAS;AAClD,QAAM,YAAY,cAAc,CAAC,MAAM,EAAE,SAAS;AAElD,QAAM,aAAa,OAAO,YAAY,aAAa,QAAQ,IAAI;AAE/D,8BAAU,MAAM;AACd,QAAI,YAAY;AACd,gBAAU,MAAM;AAAA,IAClB,OAAO;AACL,gBAAU,MAAM;AAAA,IAClB;AACA,WAAO,MAAM,UAAU,MAAM;AAAA,EAC/B,GAAG,CAAC,YAAY,QAAQ,WAAW,SAAS,CAAC;AAE7C,QAAM,oBAAgB,0BAAY,MAAM;AACtC,WAAO,OAAO,YAAY,aAAa,QAAQ,IAAI;AAAA,EACrD,GAAG,CAAC,OAAO,CAAC;AAEZ,QAAM,EAAE,QAAQ,SAAS,MAAM,QAAI,gCAAW;AAAA,IAC5C;AAAA,IACA,oBAAoB;AAAA,IACpB,cAAc;AAAA,EAChB,CAAC;AAUD,QAAM,sBAAkB,qBAAO,EAAE,QAAQ,MAAM,CAAC;AAChD,kBAAgB,UAAU,EAAE,QAAQ,MAAM;AAC1C,8BAAU,MAAM;AACd,WAAO,MAAM;AACX,UAAI,gBAAgB,QAAQ,WAAW,WAAW;AAChD,wBAAgB,QAAQ,QAAQ;AAAA,MAClC;AAAA,IACF;AAAA,EACF,GAAG,CAAC,CAAC;AAEL,8BAAU,MAAM;AACd,aAAS,mBAAmB,GAAsB;AAChD,UAAI,cAAc,SAAS,EAAE,SAAS;AACpC,UAAE,eAAe;AAAA,MACnB;AAAA,IACF;AACA,WAAO,iBAAiB,gBAAgB,kBAAkB;AAC1D,WAAO,MAAM,OAAO,oBAAoB,gBAAgB,kBAAkB;AAAA,EAC5E,GAAG,CAAC,CAAC;AAEL,SAAO;AAAA,IACL,YAAY,WAAW;AAAA,IACvB,cAAc,MAAM,UAAU;AAAA,IAC9B,aAAa,MAAM,QAAQ;AAAA,EAC7B;AACF;","names":[]}
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/use-draft.ts
2
- import { useCallback, useEffect, useId } from "react";
2
+ import { useCallback, useEffect, useId, useRef } from "react";
3
3
  import { useBlocker } from "@tanstack/react-router";
4
4
 
5
5
  // src/store.ts
@@ -41,8 +41,18 @@ function useDraft(isDirty) {
41
41
  }, [isDirty]);
42
42
  const { status, proceed, reset } = useBlocker({
43
43
  shouldBlockFn,
44
+ enableBeforeUnload: shouldBlockFn,
44
45
  withResolver: true
45
46
  });
47
+ const blockerStateRef = useRef({ status, reset });
48
+ blockerStateRef.current = { status, reset };
49
+ useEffect(() => {
50
+ return () => {
51
+ if (blockerStateRef.current.status === "blocked") {
52
+ blockerStateRef.current.reset?.();
53
+ }
54
+ };
55
+ }, []);
46
56
  useEffect(() => {
47
57
  function handleBeforeUnload(e) {
48
58
  if (useDraftStore.getState().isDirty) {
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/use-draft.ts","../src/store.ts"],"sourcesContent":["import { useCallback, useEffect, useId } from \"react\";\nimport { useBlocker } from \"@tanstack/react-router\";\nimport { useDraftStore } from \"./store\";\n\nexport interface UseDraftReturn {\n /** True when navigation is blocked and the consumer should render its confirm dialog. */\n showDialog: boolean;\n /** Proceed with the originally-attempted navigation. */\n confirmLeave: () => void;\n /** Stay on the current page, dismiss the dialog. */\n cancelLeave: () => void;\n}\n\nexport type IsDirtyInput = boolean | (() => boolean);\n\n/**\n * Track unsaved-changes state for the current form and block navigation\n * while it's dirty. The hook:\n *\n * 1. Reports this form's dirty state into the global draft store so other\n * components (e.g. an upload indicator outside the form tree) can read\n * the aggregate via `useDraftStore`.\n * 2. Hooks TanStack Router's `useBlocker` (with `withResolver: true`) so\n * in-app navigation pauses on a status === \"blocked\" condition,\n * letting the caller render a confirm dialog and call confirmLeave /\n * cancelLeave.\n * 3. Adds a `beforeunload` listener so browser refresh / tab-close\n * surfaces the native \"Leave site?\" dialog when ANY form is dirty.\n *\n * Pass a getter function (`() => isDirty`) rather than a boolean when the\n * dirty state changes inside the same event handler that triggers\n * navigation — `useBlocker` evaluates the predicate lazily, so a getter\n * reading from a `useRef` sees the synchronously-set value before React\n * has a chance to re-render.\n */\nexport function useDraft(isDirty: IsDirtyInput): UseDraftReturn {\n const formId = useId();\n const markDirty = useDraftStore((s) => s.markDirty);\n const markClean = useDraftStore((s) => s.markClean);\n\n const isDirtyNow = typeof isDirty === \"function\" ? isDirty() : isDirty;\n\n useEffect(() => {\n if (isDirtyNow) {\n markDirty(formId);\n } else {\n markClean(formId);\n }\n return () => markClean(formId);\n }, [isDirtyNow, formId, markDirty, markClean]);\n\n const shouldBlockFn = useCallback(() => {\n return typeof isDirty === \"function\" ? isDirty() : isDirty;\n }, [isDirty]);\n\n const { status, proceed, reset } = useBlocker({\n shouldBlockFn,\n withResolver: true,\n });\n\n useEffect(() => {\n function handleBeforeUnload(e: BeforeUnloadEvent) {\n if (useDraftStore.getState().isDirty) {\n e.preventDefault();\n }\n }\n window.addEventListener(\"beforeunload\", handleBeforeUnload);\n return () => window.removeEventListener(\"beforeunload\", handleBeforeUnload);\n }, []);\n\n return {\n showDialog: status === \"blocked\",\n confirmLeave: () => proceed?.(),\n cancelLeave: () => reset?.(),\n };\n}\n","import { create } from \"zustand\";\n\nexport interface DraftState {\n /** Set of form IDs currently flagged as dirty. */\n dirtyForms: Set<string>;\n /** True when at least one form is dirty. */\n isDirty: boolean;\n /** Flag the given form ID as dirty. Idempotent. */\n markDirty: (formId: string) => void;\n /** Clear the given form ID. Idempotent. */\n markClean: (formId: string) => void;\n /** Drop every dirty flag. Useful in tests / global logout. */\n reset: () => void;\n}\n\n/**\n * Global store of dirty form IDs. Any component can subscribe with\n * `useDraftStore((s) => s.isDirty)` to react to the aggregate state — e.g.\n * an upload-in-progress indicator that lives outside the form tree.\n */\nexport const useDraftStore = create<DraftState>()((set) => ({\n dirtyForms: new Set<string>(),\n isDirty: false,\n markDirty: (formId) =>\n set((state) => {\n if (state.dirtyForms.has(formId)) return state;\n const next = new Set(state.dirtyForms);\n next.add(formId);\n return { dirtyForms: next, isDirty: next.size > 0 };\n }),\n markClean: (formId) =>\n set((state) => {\n if (!state.dirtyForms.has(formId)) return state;\n const next = new Set(state.dirtyForms);\n next.delete(formId);\n return { dirtyForms: next, isDirty: next.size > 0 };\n }),\n reset: () => set({ dirtyForms: new Set<string>(), isDirty: false }),\n}));\n"],"mappings":";AAAA,SAAS,aAAa,WAAW,aAAa;AAC9C,SAAS,kBAAkB;;;ACD3B,SAAS,cAAc;AAoBhB,IAAM,gBAAgB,OAAmB,EAAE,CAAC,SAAS;AAAA,EAC1D,YAAY,oBAAI,IAAY;AAAA,EAC5B,SAAS;AAAA,EACT,WAAW,CAAC,WACV,IAAI,CAAC,UAAU;AACb,QAAI,MAAM,WAAW,IAAI,MAAM,EAAG,QAAO;AACzC,UAAM,OAAO,IAAI,IAAI,MAAM,UAAU;AACrC,SAAK,IAAI,MAAM;AACf,WAAO,EAAE,YAAY,MAAM,SAAS,KAAK,OAAO,EAAE;AAAA,EACpD,CAAC;AAAA,EACH,WAAW,CAAC,WACV,IAAI,CAAC,UAAU;AACb,QAAI,CAAC,MAAM,WAAW,IAAI,MAAM,EAAG,QAAO;AAC1C,UAAM,OAAO,IAAI,IAAI,MAAM,UAAU;AACrC,SAAK,OAAO,MAAM;AAClB,WAAO,EAAE,YAAY,MAAM,SAAS,KAAK,OAAO,EAAE;AAAA,EACpD,CAAC;AAAA,EACH,OAAO,MAAM,IAAI,EAAE,YAAY,oBAAI,IAAY,GAAG,SAAS,MAAM,CAAC;AACpE,EAAE;;;ADHK,SAAS,SAAS,SAAuC;AAC9D,QAAM,SAAS,MAAM;AACrB,QAAM,YAAY,cAAc,CAAC,MAAM,EAAE,SAAS;AAClD,QAAM,YAAY,cAAc,CAAC,MAAM,EAAE,SAAS;AAElD,QAAM,aAAa,OAAO,YAAY,aAAa,QAAQ,IAAI;AAE/D,YAAU,MAAM;AACd,QAAI,YAAY;AACd,gBAAU,MAAM;AAAA,IAClB,OAAO;AACL,gBAAU,MAAM;AAAA,IAClB;AACA,WAAO,MAAM,UAAU,MAAM;AAAA,EAC/B,GAAG,CAAC,YAAY,QAAQ,WAAW,SAAS,CAAC;AAE7C,QAAM,gBAAgB,YAAY,MAAM;AACtC,WAAO,OAAO,YAAY,aAAa,QAAQ,IAAI;AAAA,EACrD,GAAG,CAAC,OAAO,CAAC;AAEZ,QAAM,EAAE,QAAQ,SAAS,MAAM,IAAI,WAAW;AAAA,IAC5C;AAAA,IACA,cAAc;AAAA,EAChB,CAAC;AAED,YAAU,MAAM;AACd,aAAS,mBAAmB,GAAsB;AAChD,UAAI,cAAc,SAAS,EAAE,SAAS;AACpC,UAAE,eAAe;AAAA,MACnB;AAAA,IACF;AACA,WAAO,iBAAiB,gBAAgB,kBAAkB;AAC1D,WAAO,MAAM,OAAO,oBAAoB,gBAAgB,kBAAkB;AAAA,EAC5E,GAAG,CAAC,CAAC;AAEL,SAAO;AAAA,IACL,YAAY,WAAW;AAAA,IACvB,cAAc,MAAM,UAAU;AAAA,IAC9B,aAAa,MAAM,QAAQ;AAAA,EAC7B;AACF;","names":[]}
1
+ {"version":3,"sources":["../src/use-draft.ts","../src/store.ts"],"sourcesContent":["import { useCallback, useEffect, useId, useRef } from \"react\";\nimport { useBlocker } from \"@tanstack/react-router\";\nimport { useDraftStore } from \"./store\";\n\nexport interface UseDraftReturn {\n /** True when navigation is blocked and the consumer should render its confirm dialog. */\n showDialog: boolean;\n /** Proceed with the originally-attempted navigation. */\n confirmLeave: () => void;\n /** Stay on the current page, dismiss the dialog. */\n cancelLeave: () => void;\n}\n\nexport type IsDirtyInput = boolean | (() => boolean);\n\n/**\n * Track unsaved-changes state for the current form and block navigation\n * while it's dirty. The hook:\n *\n * 1. Reports this form's dirty state into the global draft store so other\n * components (e.g. an upload indicator outside the form tree) can read\n * the aggregate via `useDraftStore`.\n * 2. Hooks TanStack Router's `useBlocker` (with `withResolver: true`) so\n * in-app navigation pauses on a status === \"blocked\" condition,\n * letting the caller render a confirm dialog and call confirmLeave /\n * cancelLeave.\n * 3. Adds a `beforeunload` listener so browser refresh / tab-close\n * surfaces the native \"Leave site?\" dialog when ANY form is dirty.\n *\n * Pass a getter function (`() => isDirty`) rather than a boolean when the\n * dirty state changes inside the same event handler that triggers\n * navigation — `useBlocker` evaluates the predicate lazily, so a getter\n * reading from a `useRef` sees the synchronously-set value before React\n * has a chance to re-render.\n */\nexport function useDraft(isDirty: IsDirtyInput): UseDraftReturn {\n const formId = useId();\n const markDirty = useDraftStore((s) => s.markDirty);\n const markClean = useDraftStore((s) => s.markClean);\n\n const isDirtyNow = typeof isDirty === \"function\" ? isDirty() : isDirty;\n\n useEffect(() => {\n if (isDirtyNow) {\n markDirty(formId);\n } else {\n markClean(formId);\n }\n return () => markClean(formId);\n }, [isDirtyNow, formId, markDirty, markClean]);\n\n const shouldBlockFn = useCallback(() => {\n return typeof isDirty === \"function\" ? isDirty() : isDirty;\n }, [isDirty]);\n\n const { status, proceed, reset } = useBlocker({\n shouldBlockFn,\n enableBeforeUnload: shouldBlockFn,\n withResolver: true,\n });\n\n // If this component unmounts while a navigation is blocked (status ===\n // \"blocked\"), the underlying router promise never resolves unless\n // something calls proceed()/reset() — a consumer that forgets to render\n // the confirm dialog (or unmounts before the user answers it) would\n // otherwise leave every future navigation attempt hanging forever with\n // no dialog and no way to recover short of a full page reload. Default\n // to reset() (cancel the navigation) on unmount so the app never gets\n // permanently stuck; the user can simply retry the navigation.\n const blockerStateRef = useRef({ status, reset });\n blockerStateRef.current = { status, reset };\n useEffect(() => {\n return () => {\n if (blockerStateRef.current.status === \"blocked\") {\n blockerStateRef.current.reset?.();\n }\n };\n }, []);\n\n useEffect(() => {\n function handleBeforeUnload(e: BeforeUnloadEvent) {\n if (useDraftStore.getState().isDirty) {\n e.preventDefault();\n }\n }\n window.addEventListener(\"beforeunload\", handleBeforeUnload);\n return () => window.removeEventListener(\"beforeunload\", handleBeforeUnload);\n }, []);\n\n return {\n showDialog: status === \"blocked\",\n confirmLeave: () => proceed?.(),\n cancelLeave: () => reset?.(),\n };\n}\n","import { create } from \"zustand\";\n\nexport interface DraftState {\n /** Set of form IDs currently flagged as dirty. */\n dirtyForms: Set<string>;\n /** True when at least one form is dirty. */\n isDirty: boolean;\n /** Flag the given form ID as dirty. Idempotent. */\n markDirty: (formId: string) => void;\n /** Clear the given form ID. Idempotent. */\n markClean: (formId: string) => void;\n /** Drop every dirty flag. Useful in tests / global logout. */\n reset: () => void;\n}\n\n/**\n * Global store of dirty form IDs. Any component can subscribe with\n * `useDraftStore((s) => s.isDirty)` to react to the aggregate state — e.g.\n * an upload-in-progress indicator that lives outside the form tree.\n */\nexport const useDraftStore = create<DraftState>()((set) => ({\n dirtyForms: new Set<string>(),\n isDirty: false,\n markDirty: (formId) =>\n set((state) => {\n if (state.dirtyForms.has(formId)) return state;\n const next = new Set(state.dirtyForms);\n next.add(formId);\n return { dirtyForms: next, isDirty: next.size > 0 };\n }),\n markClean: (formId) =>\n set((state) => {\n if (!state.dirtyForms.has(formId)) return state;\n const next = new Set(state.dirtyForms);\n next.delete(formId);\n return { dirtyForms: next, isDirty: next.size > 0 };\n }),\n reset: () => set({ dirtyForms: new Set<string>(), isDirty: false }),\n}));\n"],"mappings":";AAAA,SAAS,aAAa,WAAW,OAAO,cAAc;AACtD,SAAS,kBAAkB;;;ACD3B,SAAS,cAAc;AAoBhB,IAAM,gBAAgB,OAAmB,EAAE,CAAC,SAAS;AAAA,EAC1D,YAAY,oBAAI,IAAY;AAAA,EAC5B,SAAS;AAAA,EACT,WAAW,CAAC,WACV,IAAI,CAAC,UAAU;AACb,QAAI,MAAM,WAAW,IAAI,MAAM,EAAG,QAAO;AACzC,UAAM,OAAO,IAAI,IAAI,MAAM,UAAU;AACrC,SAAK,IAAI,MAAM;AACf,WAAO,EAAE,YAAY,MAAM,SAAS,KAAK,OAAO,EAAE;AAAA,EACpD,CAAC;AAAA,EACH,WAAW,CAAC,WACV,IAAI,CAAC,UAAU;AACb,QAAI,CAAC,MAAM,WAAW,IAAI,MAAM,EAAG,QAAO;AAC1C,UAAM,OAAO,IAAI,IAAI,MAAM,UAAU;AACrC,SAAK,OAAO,MAAM;AAClB,WAAO,EAAE,YAAY,MAAM,SAAS,KAAK,OAAO,EAAE;AAAA,EACpD,CAAC;AAAA,EACH,OAAO,MAAM,IAAI,EAAE,YAAY,oBAAI,IAAY,GAAG,SAAS,MAAM,CAAC;AACpE,EAAE;;;ADHK,SAAS,SAAS,SAAuC;AAC9D,QAAM,SAAS,MAAM;AACrB,QAAM,YAAY,cAAc,CAAC,MAAM,EAAE,SAAS;AAClD,QAAM,YAAY,cAAc,CAAC,MAAM,EAAE,SAAS;AAElD,QAAM,aAAa,OAAO,YAAY,aAAa,QAAQ,IAAI;AAE/D,YAAU,MAAM;AACd,QAAI,YAAY;AACd,gBAAU,MAAM;AAAA,IAClB,OAAO;AACL,gBAAU,MAAM;AAAA,IAClB;AACA,WAAO,MAAM,UAAU,MAAM;AAAA,EAC/B,GAAG,CAAC,YAAY,QAAQ,WAAW,SAAS,CAAC;AAE7C,QAAM,gBAAgB,YAAY,MAAM;AACtC,WAAO,OAAO,YAAY,aAAa,QAAQ,IAAI;AAAA,EACrD,GAAG,CAAC,OAAO,CAAC;AAEZ,QAAM,EAAE,QAAQ,SAAS,MAAM,IAAI,WAAW;AAAA,IAC5C;AAAA,IACA,oBAAoB;AAAA,IACpB,cAAc;AAAA,EAChB,CAAC;AAUD,QAAM,kBAAkB,OAAO,EAAE,QAAQ,MAAM,CAAC;AAChD,kBAAgB,UAAU,EAAE,QAAQ,MAAM;AAC1C,YAAU,MAAM;AACd,WAAO,MAAM;AACX,UAAI,gBAAgB,QAAQ,WAAW,WAAW;AAChD,wBAAgB,QAAQ,QAAQ;AAAA,MAClC;AAAA,IACF;AAAA,EACF,GAAG,CAAC,CAAC;AAEL,YAAU,MAAM;AACd,aAAS,mBAAmB,GAAsB;AAChD,UAAI,cAAc,SAAS,EAAE,SAAS;AACpC,UAAE,eAAe;AAAA,MACnB;AAAA,IACF;AACA,WAAO,iBAAiB,gBAAgB,kBAAkB;AAC1D,WAAO,MAAM,OAAO,oBAAoB,gBAAgB,kBAAkB;AAAA,EAC5E,GAAG,CAAC,CAAC;AAEL,SAAO;AAAA,IACL,YAAY,WAAW;AAAA,IACvB,cAAc,MAAM,UAAU;AAAA,IAC9B,aAAa,MAAM,QAAQ;AAAA,EAC7B;AACF;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@idevconn/use-draft",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Global unsaved-changes blocker for TanStack Router SPAs. Aggregates dirty state from every mounted form via a Zustand store and blocks both in-app navigation (router dialog) and browser refresh/close (native dialog).",
5
5
  "license": "Apache-2.0",
6
6
  "author": "iDEVconn",