gemi 0.46.1 → 0.48.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.
@@ -460,391 +460,6 @@ function useQuery(url, ...args) {
460
460
  };
461
461
  }
462
462
  //#endregion
463
- //#region client/useMutation.ts
464
- function applyParams(url, params = {}) {
465
- let out = url;
466
- for (const [key, value] of Object.entries(params)) out = out.replace(`:${key}?`, value).replace(`:${key}`, value);
467
- return out;
468
- }
469
- var defaultOptions = {
470
- autoInvalidate: false,
471
- onSuccess: () => {},
472
- onError: (_) => {},
473
- onCanceled: () => {}
474
- };
475
- function useMutation(method, url, ...args) {
476
- const _params = useParams();
477
- const [state, setState] = useState({
478
- data: null,
479
- error: null,
480
- loading: false
481
- });
482
- const [abortController, setAbortController] = useState(() => new AbortController());
483
- const formData = useRef(new FormData());
484
- async function trigger(input) {
485
- setState({
486
- data: state.data,
487
- error: state.error,
488
- loading: true
489
- });
490
- const [inputs = {}, options = defaultOptions] = args ?? [];
491
- const params = "params" in inputs ? {
492
- ..._params,
493
- ...inputs.params
494
- } : _params;
495
- const search = "search" in inputs ? inputs.search : {};
496
- const searchParams = new URLSearchParams(search);
497
- const finalUrl = [applyParams(String(url).replace(`${method}:`, ""), params), searchParams.toString()].filter(Boolean).join("?");
498
- let body = null;
499
- const contentType = typeof input === "undefined" || input instanceof FormData ? {} : { "Content-Type": "application/json" };
500
- if (input instanceof FormData) body = input;
501
- else if (typeof input === "undefined") body = formData.current;
502
- else if (input) body = JSON.stringify(input);
503
- try {
504
- const response = await fetch(`/api${finalUrl}`, {
505
- method,
506
- headers: { ...contentType },
507
- ...body ? { body } : {},
508
- signal: abortController.signal
509
- });
510
- formData.current = new FormData();
511
- const data = await response.json();
512
- if (!response.ok) {
513
- setState({
514
- data: null,
515
- error: data.error,
516
- loading: false
517
- });
518
- options?.onError?.(data);
519
- return;
520
- }
521
- options.onSuccess(data);
522
- setState({
523
- data,
524
- error: null,
525
- loading: false
526
- });
527
- return data;
528
- } catch (error) {
529
- formData.current = new FormData();
530
- options?.onError?.(error);
531
- setState({
532
- data: null,
533
- error,
534
- loading: false
535
- });
536
- }
537
- }
538
- trigger.formData = (formData) => {
539
- return trigger(formData);
540
- };
541
- return {
542
- data: state.data,
543
- error: state.error,
544
- loading: state.loading,
545
- formData: formData.current,
546
- cancel: () => {
547
- const [, options = defaultOptions] = args ?? [];
548
- abortController.abort();
549
- setAbortController(new AbortController());
550
- setState({
551
- data: state.data,
552
- error: state.error,
553
- loading: false
554
- });
555
- formData.current = new FormData();
556
- options.onCanceled();
557
- },
558
- trigger
559
- };
560
- }
561
- function usePost(url, ...args) {
562
- return useMutation("POST", url, ...args);
563
- }
564
- function usePut(url, ...args) {
565
- return useMutation("PUT", url, ...args);
566
- }
567
- function usePatch(url, ...args) {
568
- return useMutation("PATCH", url, ...args);
569
- }
570
- function useDelete(url, ...args) {
571
- return useMutation("DELETE", url, ...args);
572
- }
573
- function useUpload(url, ...args) {
574
- const [state, setState] = useState("idle");
575
- const [progress, setProgress] = useState(0);
576
- const _params = useParams();
577
- const abortRef = useRef(null);
578
- const [inputs = {}, options = defaultOptions] = args ?? [];
579
- const cancel = () => {
580
- if (abortRef.current) {
581
- abortRef.current();
582
- options.onCanceled?.();
583
- setState("idle");
584
- setProgress(0);
585
- }
586
- };
587
- const trigger = async (fileList) => {
588
- if (!fileList) return;
589
- const params = "params" in inputs ? {
590
- ..._params,
591
- ...inputs.params
592
- } : _params;
593
- const finalUrl = applyParams(String(url).replace("POST:", ""), params);
594
- const method = "POST";
595
- const action = `/api${finalUrl}`;
596
- const data = new FormData();
597
- if (fileList instanceof FileList) for (const file of Array.from(fileList)) data.append("file", file);
598
- else data.append("file", fileList);
599
- const xhr = new XMLHttpRequest();
600
- abortRef.current = () => {
601
- xhr.abort();
602
- };
603
- try {
604
- const result = await new Promise((resolve, reject) => {
605
- xhr.responseType = "blob";
606
- xhr.onreadystatechange = async () => {
607
- if (xhr.readyState !== 4) return;
608
- resolve(new Response(xhr.response, {
609
- status: xhr.status,
610
- statusText: xhr.statusText
611
- }));
612
- };
613
- xhr.addEventListener("error", () => {
614
- reject(/* @__PURE__ */ new TypeError("Failed to fetch"));
615
- });
616
- xhr.upload.addEventListener("loadstart", () => {
617
- setProgress(0);
618
- });
619
- xhr.upload.addEventListener("loadend", () => {
620
- setProgress(1);
621
- });
622
- xhr.upload.addEventListener("progress", (event) => {
623
- setProgress(event.loaded / event.total);
624
- });
625
- xhr.open(method, action, true);
626
- xhr.send(data);
627
- });
628
- setState("uploading");
629
- if (!result.ok) {
630
- let error = {
631
- kind: "server_error",
632
- message: result.statusText
633
- };
634
- try {
635
- error = (await result.json()).error;
636
- } catch (e) {}
637
- setState("error");
638
- options?.onError?.(error);
639
- return;
640
- }
641
- const json = await result.json();
642
- options?.onSuccess?.(json);
643
- return json;
644
- } catch (error) {
645
- setState("error");
646
- options?.onError?.(error);
647
- return;
648
- }
649
- };
650
- return {
651
- state,
652
- progress,
653
- trigger,
654
- cancel
655
- };
656
- }
657
- //#endregion
658
- //#region client/useMutate.ts
659
- function useMutate() {
660
- const { getResource } = useContext(QueryManagerContext);
661
- return function mutate(options, fn) {
662
- const { path, params = {}, search = {} } = options ?? {};
663
- const normalPath = applyParams$1(path, params);
664
- const resource = getResource(normalPath);
665
- const searchParams = new URLSearchParams(omitNullishValues(search));
666
- searchParams.sort();
667
- const variantKey = searchParams.toString();
668
- return resource.mutate.call(resource, variantKey, (data) => {
669
- if (data === void 0 || data === null) {
670
- console.warn("Mutate function called before the query.");
671
- return data;
672
- }
673
- if (!fn) return data;
674
- const updatedData = typeof fn === "function" ? fn(data) : fn;
675
- if (isPlainObject(data)) {
676
- if (isPlainObject(updatedData)) return updatedData;
677
- throw new Error("Mutate function must return an object when the current data is an object.");
678
- }
679
- if (Array.isArray(data)) {
680
- if (Array.isArray(updatedData)) return updatedData;
681
- throw new Error("Mutate function must return an array when the current data is an array.");
682
- }
683
- if (typeof data !== typeof updatedData) throw new Error("Mutate function must return the same type as the current data.");
684
- return updatedData;
685
- });
686
- };
687
- }
688
- //#endregion
689
- //#region client/ServerDataProvider.tsx
690
- var ServerDataContext = createContext({});
691
- var ServerDataProvider = (props) => {
692
- let _value = props.value;
693
- if (props.value) _value = props.value;
694
- else _value = window.__GEMI_DATA__;
695
- return /* @__PURE__ */ jsx(ServerDataContext.Provider, {
696
- value: _value,
697
- children: props.children
698
- });
699
- };
700
- //#endregion
701
- //#region client/Mutation.tsx
702
- var MutationContext = createContext({
703
- isPending: false,
704
- result: null
705
- });
706
- function Form(props) {
707
- const _params = useParams();
708
- const { method = "POST", action, onSuccess = () => {}, onError = () => {}, params, search = {}, className, dynamicInputs = () => ({}), ...formProps } = "params" in props ? {
709
- ...props,
710
- params: {
711
- ..._params,
712
- ...props.params
713
- }
714
- } : {
715
- ...props,
716
- params: _params
717
- };
718
- const formRef = useRef(null);
719
- const { __csrf } = useContext(ServerDataContext);
720
- const formDataSubject = useRef(new Subject(new FormData()));
721
- const updateFormData = useCallback(() => {
722
- formDataSubject.current.next(new FormData(formRef.current));
723
- }, []);
724
- useEffect(() => {
725
- if (!formRef.current) return;
726
- formRef.current.addEventListener("input", updateFormData);
727
- const observer = new MutationObserver(() => {
728
- const formData = new FormData(formRef.current);
729
- formDataSubject.current.next(formData);
730
- });
731
- formRef.current.querySelectorAll("input").forEach((input) => observer.observe(input, {
732
- attributes: true,
733
- attributeFilter: ["value"]
734
- }));
735
- formRef.current.querySelectorAll("select").forEach((input) => observer.observe(input, {
736
- attributes: true,
737
- attributeFilter: ["value"]
738
- }));
739
- formRef.current.querySelectorAll("textarea").forEach((input) => observer.observe(input, {
740
- attributes: true,
741
- attributeFilter: ["value"]
742
- }));
743
- return () => {
744
- observer.disconnect();
745
- if (formRef.current) formRef.current.removeEventListener("input", updateFormData);
746
- };
747
- }, [updateFormData]);
748
- const { trigger, data, error, loading } = useMutation(method, String(action), {
749
- params,
750
- search
751
- }, {
752
- onSuccess: (data) => onSuccess(data, formRef.current),
753
- onError: (error) => onError(error, formRef.current)
754
- });
755
- const handleSubmit = async (e) => {
756
- if (loading) return;
757
- e.preventDefault();
758
- if (!formRef.current) return;
759
- const formData = new FormData(formRef.current);
760
- for (const [key, value] of Object.entries(dynamicInputs(formData))) formData.append(key, value);
761
- trigger(formData);
762
- };
763
- const validationErrors = error?.kind === "validation_error" ? error.messages : {};
764
- const formError = error?.kind === "form_error" ? error.message : null;
765
- return /* @__PURE__ */ jsx(MutationContext.Provider, {
766
- value: {
767
- isPending: loading,
768
- result: data,
769
- validationErrors,
770
- formError,
771
- formDataSubject
772
- },
773
- children: /* @__PURE__ */ jsxs("form", {
774
- className: ["group", className].filter(Boolean).join(" "),
775
- "data-loading": loading,
776
- ref: formRef,
777
- onSubmit: handleSubmit,
778
- ...formProps,
779
- children: [/* @__PURE__ */ jsx("input", {
780
- type: "hidden",
781
- name: "__csrf",
782
- value: __csrf
783
- }), props.children]
784
- })
785
- });
786
- }
787
- function useMutationStatus() {
788
- const { isPending } = useContext(MutationContext);
789
- return { isPending };
790
- }
791
- function useFormStatus() {
792
- const { isPending, validationErrors, formError } = useContext(MutationContext);
793
- return {
794
- isPending,
795
- validationErrors,
796
- formError
797
- };
798
- }
799
- function useFormData() {
800
- const { formDataSubject } = useContext(MutationContext);
801
- return useSyncExternalStore(formDataSubject.current.subscribe.bind(formDataSubject.current), formDataSubject.current.getValue.bind(formDataSubject.current), formDataSubject.current.getValue.bind(formDataSubject.current));
802
- }
803
- var ValidationErrors = (props) => {
804
- const { render = (props) => /* @__PURE__ */ jsx("div", { ...props }), name } = props;
805
- const { validationErrors } = useContext(MutationContext);
806
- const Comp = render;
807
- if (validationErrors[name]?.length > 0) return /* @__PURE__ */ jsx(Fragment$1, { children: validationErrors[name].map((error) => {
808
- return /* @__PURE__ */ jsx(Comp, {
809
- className: props.className,
810
- children: error
811
- }, error);
812
- }) });
813
- return null;
814
- };
815
- var FormFieldContainer = (props) => {
816
- const { name, children, ...rest } = props;
817
- const { validationErrors } = useContext(MutationContext);
818
- return /* @__PURE__ */ jsx("div", {
819
- "data-has-error": (validationErrors[name] || []).length > 0,
820
- ...rest,
821
- children
822
- });
823
- };
824
- var FormError = (props) => {
825
- const { formError } = useContext(MutationContext);
826
- if (formError) return /* @__PURE__ */ jsx("div", {
827
- ...props,
828
- children: formError
829
- });
830
- return null;
831
- };
832
- //#endregion
833
- //#region client/useLocation.ts
834
- function useLocation() {
835
- const ctx = useContext(RouteStateContext);
836
- if (!ctx) throw new Error("Router context not found");
837
- const { hash, pathname, search, state, locale } = ctx;
838
- return {
839
- hash,
840
- key: pathname,
841
- pathname,
842
- search,
843
- state,
844
- locale
845
- };
846
- }
847
- //#endregion
848
463
  //#region ../../node_modules/.bun/@babel+runtime@7.29.2/node_modules/@babel/runtime/helpers/esm/extends.js
849
464
  function _extends() {
850
465
  return _extends = Object.assign ? Object.assign.bind() : function(n) {
@@ -2103,45 +1718,31 @@ var ProgressManager = class {
2103
1718
  }
2104
1719
  };
2105
1720
  //#endregion
2106
- //#region client/useRoute.ts
2107
- function useRoute() {
2108
- const { pathname: _pathname } = useContext(RouteStateContext);
1721
+ //#region client/useLocation.ts
1722
+ function useLocation() {
1723
+ const ctx = useContext(RouteStateContext);
1724
+ if (!ctx) throw new Error("Router context not found");
1725
+ const { hash, pathname, search, state, locale } = ctx;
2109
1726
  return {
2110
- pathname: _pathname,
2111
- startsWith: (pathname) => {
2112
- return _pathname.startsWith(pathname);
2113
- }
1727
+ hash,
1728
+ key: pathname,
1729
+ pathname,
1730
+ search,
1731
+ state,
1732
+ locale
2114
1733
  };
2115
1734
  }
2116
1735
  //#endregion
2117
- //#region client/HttpReload.tsx
2118
- var HttpReload = () => {
2119
- const { replace } = useNavigate();
2120
- useSearchParams();
2121
- const { pathname } = useRoute();
2122
- useParams();
2123
- const [reloading, setReloading] = useState(false);
2124
- const handleReload = () => {
2125
- if (typeof document !== "undefined") document.querySelectorAll("vite-error-overlay").forEach((el) => {
2126
- if (typeof el.close === "function") el.close();
2127
- else el.remove();
2128
- });
2129
- setReloading(true);
2130
- };
2131
- useEffect(() => {
2132
- if (import.meta.hot) import.meta.hot.on("http-reload", handleReload);
2133
- return () => {
2134
- if (import.meta.hot) import.meta.hot.off("http-reload", handleReload);
2135
- };
2136
- }, [handleReload]);
2137
- if (!reloading || typeof document === "undefined") return null;
2138
- return createPortal(/* @__PURE__ */ jsx("div", {
2139
- className: "fixed z-[1000] bottom-0 right-0 p-2",
2140
- children: /* @__PURE__ */ jsx("div", {
2141
- className: "p-2 bg-white text-black rounded-md shadow-md",
2142
- children: "..."
2143
- })
2144
- }), document.body);
1736
+ //#region client/ServerDataProvider.tsx
1737
+ var ServerDataContext = createContext({});
1738
+ var ServerDataProvider = (props) => {
1739
+ let _value = props.value;
1740
+ if (props.value) _value = props.value;
1741
+ else _value = window.__GEMI_DATA__;
1742
+ return /* @__PURE__ */ jsx(ServerDataContext.Provider, {
1743
+ value: _value,
1744
+ children: props.children
1745
+ });
2145
1746
  };
2146
1747
  //#endregion
2147
1748
  //#region client/I18nContext.tsx
@@ -2195,6 +1796,246 @@ var I18nProvider = (props) => {
2195
1796
  });
2196
1797
  };
2197
1798
  //#endregion
1799
+ //#region client/useNavigate.ts
1800
+ function useNavigate() {
1801
+ const { history, setNavigationAbortController } = useContext(ClientRouterContext);
1802
+ const { defaultLocale } = useContext(I18nContext);
1803
+ const location = useLocation();
1804
+ function action(pushOrReplace) {
1805
+ return async (path, ...args) => {
1806
+ const navigationAbortController = new AbortController();
1807
+ if (setNavigationAbortController) setNavigationAbortController(navigationAbortController);
1808
+ const [options = {}] = args;
1809
+ const { search = {}, params = {}, shallow, locale, hash } = {
1810
+ params: {},
1811
+ shallow: false,
1812
+ locale: null,
1813
+ hash: "",
1814
+ ...options
1815
+ };
1816
+ const urlSearchParams = new URLSearchParams(search);
1817
+ let localeSegment = location.locale;
1818
+ if (locale) localeSegment = locale;
1819
+ if (localeSegment === defaultLocale) localeSegment = "";
1820
+ const routePath = applyParams$1(path, params);
1821
+ const finalPath = [[`${localeSegment ? `/${localeSegment}` : ""}${routePath === "/" ? "" : routePath}`, urlSearchParams.toString()].filter((s) => s.length > 0).join("?"), hash].filter(Boolean).join("");
1822
+ if (shallow) {
1823
+ history?.[pushOrReplace](finalPath, { shallow });
1824
+ return;
1825
+ }
1826
+ history?.[pushOrReplace](finalPath === "" ? "/" : finalPath);
1827
+ };
1828
+ }
1829
+ return {
1830
+ push: action("push"),
1831
+ replace: action("replace")
1832
+ };
1833
+ }
1834
+ //#endregion
1835
+ //#region client/useSearchParams.ts
1836
+ var SearchParams = class {
1837
+ searchParams;
1838
+ callback;
1839
+ constructor(searchParams, callback) {
1840
+ this.searchParams = searchParams;
1841
+ this.callback = callback;
1842
+ }
1843
+ get(key) {
1844
+ return this.searchParams.get(key);
1845
+ }
1846
+ set(key, value) {
1847
+ let entries = {};
1848
+ if (typeof key === "string") {
1849
+ let _value = value;
1850
+ if (typeof value === "function") _value = value(this.get(key) ?? "");
1851
+ entries[key] = _value;
1852
+ } else entries = key ?? {};
1853
+ for (const [key, value] of Object.entries(entries)) {
1854
+ let _value = value;
1855
+ if (typeof value === "function") _value = value(this.get(key) ?? "");
1856
+ this.searchParams.set(key, _value);
1857
+ }
1858
+ return this;
1859
+ }
1860
+ append(key, value) {
1861
+ this.searchParams.append(key, value);
1862
+ return this;
1863
+ }
1864
+ sort() {
1865
+ this.searchParams.sort();
1866
+ return this;
1867
+ }
1868
+ clear() {
1869
+ this.searchParams = new URLSearchParams();
1870
+ return this;
1871
+ }
1872
+ delete(key) {
1873
+ const keys = Array.isArray(key) ? key : [key];
1874
+ for (const key of keys) this.searchParams.delete(key);
1875
+ return this;
1876
+ }
1877
+ toJSON() {
1878
+ const map = /* @__PURE__ */ new Map();
1879
+ for (const [key, value] of this.searchParams) if (map.has(key)) {
1880
+ const currentValue = map.get(key);
1881
+ if (Array.isArray(currentValue)) {
1882
+ currentValue.push(value);
1883
+ map.set(key, currentValue);
1884
+ } else map.set(key, [currentValue, value]);
1885
+ } else map.set(key, value);
1886
+ return Object.fromEntries(map.entries());
1887
+ }
1888
+ toString() {
1889
+ return this.searchParams.toString();
1890
+ }
1891
+ push(mode = "soft") {
1892
+ this.callback(this.toJSON(), mode === "soft");
1893
+ }
1894
+ };
1895
+ function useSearchParams() {
1896
+ const { push } = useNavigate();
1897
+ const { search, pathname } = useContext(RouteStateContext);
1898
+ const params = useParams();
1899
+ const callback = (search, shallow) => {
1900
+ push(pathname, {
1901
+ params,
1902
+ search,
1903
+ shallow
1904
+ });
1905
+ };
1906
+ return new SearchParams(new URLSearchParams(search), callback);
1907
+ }
1908
+ //#endregion
1909
+ //#region client/useRoute.ts
1910
+ function useRoute() {
1911
+ const { pathname: _pathname } = useContext(RouteStateContext);
1912
+ return {
1913
+ pathname: _pathname,
1914
+ startsWith: (pathname) => {
1915
+ return _pathname.startsWith(pathname);
1916
+ }
1917
+ };
1918
+ }
1919
+ //#endregion
1920
+ //#region client/HttpReload.tsx
1921
+ var HttpReload = () => {
1922
+ const { replace } = useNavigate();
1923
+ useSearchParams();
1924
+ const { pathname } = useRoute();
1925
+ useParams();
1926
+ const [reloading, setReloading] = useState(false);
1927
+ const handleReload = () => {
1928
+ if (typeof document !== "undefined") document.querySelectorAll("vite-error-overlay").forEach((el) => {
1929
+ if (typeof el.close === "function") el.close();
1930
+ else el.remove();
1931
+ });
1932
+ setReloading(true);
1933
+ };
1934
+ useEffect(() => {
1935
+ if (import.meta.hot) import.meta.hot.on("http-reload", handleReload);
1936
+ return () => {
1937
+ if (import.meta.hot) import.meta.hot.off("http-reload", handleReload);
1938
+ };
1939
+ }, [handleReload]);
1940
+ if (!reloading || typeof document === "undefined") return null;
1941
+ return createPortal(/* @__PURE__ */ jsx("div", {
1942
+ className: "fixed z-[1000] bottom-0 right-0 p-2",
1943
+ children: /* @__PURE__ */ jsx("div", {
1944
+ className: "p-2 bg-white text-black rounded-md shadow-md",
1945
+ children: "..."
1946
+ })
1947
+ }), document.body);
1948
+ };
1949
+ //#endregion
1950
+ //#region client/PrefetchCache.ts
1951
+ /**
1952
+ * How long a prefetched payload stays usable. Long enough to cover the gap
1953
+ * between hovering a link and clicking it, short enough that a navigation is
1954
+ * not served a snapshot the visitor would notice as out of date.
1955
+ *
1956
+ * A payload cached here is committed wholesale on navigation — including into
1957
+ * the query cache via `hydrate` — so anything that invalidates page data has to
1958
+ * `clear()` this too. `useMutation` does exactly that on every successful
1959
+ * write; locale needs no such call, since the locale segment is part of the key.
1960
+ */
1961
+ var PREFETCH_TTL = 1e4;
1962
+ /**
1963
+ * Payloads fetched ahead of a navigation, keyed by the `.json` URL that
1964
+ * navigation would request. Entries are handed over once — a navigation that
1965
+ * consumes one becomes the live route data, so keeping a copy around would only
1966
+ * let a later visit render from a stale snapshot.
1967
+ */
1968
+ var PrefetchCache = class {
1969
+ entries = /* @__PURE__ */ new Map();
1970
+ isFresh(entry) {
1971
+ return Date.now() - entry.createdAt < PREFETCH_TTL;
1972
+ }
1973
+ /** Drops what has expired, then the oldest of whatever is still over budget. */
1974
+ evict() {
1975
+ for (const [url, entry] of this.entries) if (!this.isFresh(entry)) this.entries.delete(url);
1976
+ while (this.entries.size >= 12) {
1977
+ const oldest = this.entries.keys().next().value;
1978
+ if (oldest === void 0) return;
1979
+ this.entries.delete(oldest);
1980
+ }
1981
+ }
1982
+ /**
1983
+ * Runs `load` unless the same URL is already in flight or freshly cached, so
1984
+ * a link hovered repeatedly — or a screenful of eagerly prefetched links
1985
+ * pointing at one route — costs a single request.
1986
+ */
1987
+ prime(url, load) {
1988
+ const existing = this.entries.get(url);
1989
+ if (existing && this.isFresh(existing)) return existing.promise;
1990
+ this.evict();
1991
+ const entry = {
1992
+ createdAt: Date.now(),
1993
+ promise: null
1994
+ };
1995
+ entry.promise = load().catch(() => null).then((payload) => {
1996
+ if (payload == null && this.entries.get(url) === entry) this.entries.delete(url);
1997
+ return payload;
1998
+ });
1999
+ this.entries.set(url, entry);
2000
+ return entry.promise;
2001
+ }
2002
+ /**
2003
+ * Hands the payload for `url` to a navigation, if one was prefetched and is
2004
+ * still fresh. Resolves to `null` when the prefetch failed, which callers
2005
+ * must treat as a miss.
2006
+ */
2007
+ take(url) {
2008
+ const entry = this.entries.get(url);
2009
+ if (!entry) return null;
2010
+ this.entries.delete(url);
2011
+ return this.isFresh(entry) ? entry.promise : null;
2012
+ }
2013
+ /**
2014
+ * Drops everything, for when the data behind these payloads may have moved
2015
+ * on. In-flight loads still settle; their entries are simply gone by then.
2016
+ */
2017
+ clear() {
2018
+ this.entries.clear();
2019
+ }
2020
+ /** Retained entries, fresh or not. Exposed for tests. */
2021
+ get size() {
2022
+ return this.entries.size;
2023
+ }
2024
+ };
2025
+ //#endregion
2026
+ //#region client/helpers/routeDataUrl.ts
2027
+ /**
2028
+ * The `.json` URL a route's page data is served from.
2029
+ *
2030
+ * Prefetching and navigation have to agree on this string exactly — the
2031
+ * prefetch cache is keyed by it, and any disagreement silently turns every
2032
+ * prefetch into a wasted request plus a full fetch on click.
2033
+ */
2034
+ function routeDataUrl(options) {
2035
+ const { pathname, search = "", localeSegment = "" } = options;
2036
+ return `${localeSegment}${localeSegment.length > 0 && pathname === "/" ? "" : pathname}.json${search}`;
2037
+ }
2038
+ //#endregion
2198
2039
  //#region client/ClientRouterContext.tsx
2199
2040
  var ClientRouterContext = createContext({});
2200
2041
  var ClientRouterProvider = (props) => {
@@ -2205,6 +2046,7 @@ var ClientRouterProvider = (props) => {
2205
2046
  });
2206
2047
  const { supportedLocales = [], locale } = useContext(I18nContext);
2207
2048
  const [progressManager] = useState(new ProgressManager(isNavigatingSubject));
2049
+ const [prefetchCache] = useState(() => new PrefetchCache());
2208
2050
  const pageDataRef = useRef(structuredClone(pageData));
2209
2051
  const scrollHistoryRef = useRef(/* @__PURE__ */ new Map());
2210
2052
  const breadcrumbsCache = useRef(new Map(Object.entries(breadcrumbs)));
@@ -2320,9 +2162,43 @@ var ClientRouterProvider = (props) => {
2320
2162
  document.head.appendChild(style);
2321
2163
  }
2322
2164
  };
2165
+ /**
2166
+ * Warms everything a navigation to `target` would need: the route's page
2167
+ * data, its stylesheets and its component chunks.
2168
+ *
2169
+ * The payload is requested *without* the partial-render header, because the
2170
+ * route on screen when the link is prefetched is not necessarily the one it
2171
+ * will be clicked from — a partial response computed against the wrong base
2172
+ * has nothing sound to merge onto. A full payload is always safe to commit.
2173
+ *
2174
+ * It carries `Purpose: prefetch` so applications can tell speculative traffic
2175
+ * from a real visit — a route's handlers run either way, and a `viewport`
2176
+ * page multiplies that by the number of links on it.
2177
+ */
2178
+ const prefetchRoute = async (target) => {
2179
+ if (typeof window === "undefined") return;
2180
+ const { pathname, search = "", localeSegment = "" } = target;
2181
+ const routePath = getRoutePathnameFromHref(pathname);
2182
+ if (!routePath) return;
2183
+ const url = routeDataUrl({
2184
+ pathname,
2185
+ search,
2186
+ localeSegment
2187
+ });
2188
+ fetchRouteCSS(routePath).catch(() => {});
2189
+ for (const view of routeManifest[routePath] ?? []) window?.loaders?.[view]?.();
2190
+ await prefetchCache.prime(url, async () => {
2191
+ const response = await fetch(url, { headers: { Purpose: "prefetch" } });
2192
+ if (!response.ok) return null;
2193
+ return await response.json();
2194
+ });
2195
+ };
2323
2196
  return /* @__PURE__ */ jsxs(ClientRouterContext.Provider, {
2324
2197
  value: {
2325
2198
  isNavigatingSubject,
2199
+ prefetchRoute,
2200
+ takePrefetched: (url) => prefetchCache.take(url),
2201
+ clearPrefetchCache: () => prefetchCache.clear(),
2326
2202
  getViewPathsFromPathname,
2327
2203
  history,
2328
2204
  getScrollPosition: (path) => {
@@ -2343,115 +2219,367 @@ var ClientRouterProvider = (props) => {
2343
2219
  });
2344
2220
  };
2345
2221
  //#endregion
2346
- //#region client/useNavigate.ts
2347
- function useNavigate() {
2348
- const { history, setNavigationAbortController } = useContext(ClientRouterContext);
2349
- const { defaultLocale } = useContext(I18nContext);
2350
- const location = useLocation();
2351
- function action(pushOrReplace) {
2352
- return async (path, ...args) => {
2353
- const navigationAbortController = new AbortController();
2354
- if (setNavigationAbortController) setNavigationAbortController(navigationAbortController);
2355
- const [options = {}] = args;
2356
- const { search = {}, params = {}, shallow, locale, hash } = {
2357
- params: {},
2358
- shallow: false,
2359
- locale: null,
2360
- hash: "",
2361
- ...options
2362
- };
2363
- const urlSearchParams = new URLSearchParams(search);
2364
- let localeSegment = location.locale;
2365
- if (locale) localeSegment = locale;
2366
- if (localeSegment === defaultLocale) localeSegment = "";
2367
- const routePath = applyParams$1(path, params);
2368
- const finalPath = [[`${localeSegment ? `/${localeSegment}` : ""}${routePath === "/" ? "" : routePath}`, urlSearchParams.toString()].filter((s) => s.length > 0).join("?"), hash].filter(Boolean).join("");
2369
- if (shallow) {
2370
- history?.[pushOrReplace](finalPath, { shallow });
2222
+ //#region client/useMutation.ts
2223
+ function applyParams(url, params = {}) {
2224
+ let out = url;
2225
+ for (const [key, value] of Object.entries(params)) out = out.replace(`:${key}?`, value).replace(`:${key}`, value);
2226
+ return out;
2227
+ }
2228
+ var defaultOptions = {
2229
+ autoInvalidate: false,
2230
+ onSuccess: () => {},
2231
+ onError: (_) => {},
2232
+ onCanceled: () => {}
2233
+ };
2234
+ function useMutation(method, url, ...args) {
2235
+ const _params = useParams();
2236
+ const { clearPrefetchCache } = useContext(ClientRouterContext);
2237
+ const [state, setState] = useState({
2238
+ data: null,
2239
+ error: null,
2240
+ loading: false
2241
+ });
2242
+ const [abortController, setAbortController] = useState(() => new AbortController());
2243
+ const formData = useRef(new FormData());
2244
+ async function trigger(input) {
2245
+ setState({
2246
+ data: state.data,
2247
+ error: state.error,
2248
+ loading: true
2249
+ });
2250
+ const [inputs = {}, options = defaultOptions] = args ?? [];
2251
+ const params = "params" in inputs ? {
2252
+ ..._params,
2253
+ ...inputs.params
2254
+ } : _params;
2255
+ const search = "search" in inputs ? inputs.search : {};
2256
+ const searchParams = new URLSearchParams(search);
2257
+ const finalUrl = [applyParams(String(url).replace(`${method}:`, ""), params), searchParams.toString()].filter(Boolean).join("?");
2258
+ let body = null;
2259
+ const contentType = typeof input === "undefined" || input instanceof FormData ? {} : { "Content-Type": "application/json" };
2260
+ if (input instanceof FormData) body = input;
2261
+ else if (typeof input === "undefined") body = formData.current;
2262
+ else if (input) body = JSON.stringify(input);
2263
+ try {
2264
+ const response = await fetch(`/api${finalUrl}`, {
2265
+ method,
2266
+ headers: { ...contentType },
2267
+ ...body ? { body } : {},
2268
+ signal: abortController.signal
2269
+ });
2270
+ formData.current = new FormData();
2271
+ const data = await response.json();
2272
+ if (!response.ok) {
2273
+ setState({
2274
+ data: null,
2275
+ error: data.error,
2276
+ loading: false
2277
+ });
2278
+ options?.onError?.(data);
2279
+ return;
2280
+ }
2281
+ clearPrefetchCache?.();
2282
+ options.onSuccess(data);
2283
+ setState({
2284
+ data,
2285
+ error: null,
2286
+ loading: false
2287
+ });
2288
+ return data;
2289
+ } catch (error) {
2290
+ formData.current = new FormData();
2291
+ options?.onError?.(error);
2292
+ setState({
2293
+ data: null,
2294
+ error,
2295
+ loading: false
2296
+ });
2297
+ }
2298
+ }
2299
+ trigger.formData = (formData) => {
2300
+ return trigger(formData);
2301
+ };
2302
+ return {
2303
+ data: state.data,
2304
+ error: state.error,
2305
+ loading: state.loading,
2306
+ formData: formData.current,
2307
+ cancel: () => {
2308
+ const [, options = defaultOptions] = args ?? [];
2309
+ abortController.abort();
2310
+ setAbortController(new AbortController());
2311
+ setState({
2312
+ data: state.data,
2313
+ error: state.error,
2314
+ loading: false
2315
+ });
2316
+ formData.current = new FormData();
2317
+ options.onCanceled();
2318
+ },
2319
+ trigger
2320
+ };
2321
+ }
2322
+ function usePost(url, ...args) {
2323
+ return useMutation("POST", url, ...args);
2324
+ }
2325
+ function usePut(url, ...args) {
2326
+ return useMutation("PUT", url, ...args);
2327
+ }
2328
+ function usePatch(url, ...args) {
2329
+ return useMutation("PATCH", url, ...args);
2330
+ }
2331
+ function useDelete(url, ...args) {
2332
+ return useMutation("DELETE", url, ...args);
2333
+ }
2334
+ function useUpload(url, ...args) {
2335
+ const [state, setState] = useState("idle");
2336
+ const [progress, setProgress] = useState(0);
2337
+ const _params = useParams();
2338
+ const { clearPrefetchCache } = useContext(ClientRouterContext);
2339
+ const abortRef = useRef(null);
2340
+ const [inputs = {}, options = defaultOptions] = args ?? [];
2341
+ const cancel = () => {
2342
+ if (abortRef.current) {
2343
+ abortRef.current();
2344
+ options.onCanceled?.();
2345
+ setState("idle");
2346
+ setProgress(0);
2347
+ }
2348
+ };
2349
+ const trigger = async (fileList) => {
2350
+ if (!fileList) return;
2351
+ const params = "params" in inputs ? {
2352
+ ..._params,
2353
+ ...inputs.params
2354
+ } : _params;
2355
+ const finalUrl = applyParams(String(url).replace("POST:", ""), params);
2356
+ const method = "POST";
2357
+ const action = `/api${finalUrl}`;
2358
+ const data = new FormData();
2359
+ if (fileList instanceof FileList) for (const file of Array.from(fileList)) data.append("file", file);
2360
+ else data.append("file", fileList);
2361
+ const xhr = new XMLHttpRequest();
2362
+ abortRef.current = () => {
2363
+ xhr.abort();
2364
+ };
2365
+ try {
2366
+ const result = await new Promise((resolve, reject) => {
2367
+ xhr.responseType = "blob";
2368
+ xhr.onreadystatechange = async () => {
2369
+ if (xhr.readyState !== 4) return;
2370
+ resolve(new Response(xhr.response, {
2371
+ status: xhr.status,
2372
+ statusText: xhr.statusText
2373
+ }));
2374
+ };
2375
+ xhr.addEventListener("error", () => {
2376
+ reject(/* @__PURE__ */ new TypeError("Failed to fetch"));
2377
+ });
2378
+ xhr.upload.addEventListener("loadstart", () => {
2379
+ setProgress(0);
2380
+ });
2381
+ xhr.upload.addEventListener("loadend", () => {
2382
+ setProgress(1);
2383
+ });
2384
+ xhr.upload.addEventListener("progress", (event) => {
2385
+ setProgress(event.loaded / event.total);
2386
+ });
2387
+ xhr.open(method, action, true);
2388
+ xhr.send(data);
2389
+ });
2390
+ setState("uploading");
2391
+ if (!result.ok) {
2392
+ let error = {
2393
+ kind: "server_error",
2394
+ message: result.statusText
2395
+ };
2396
+ try {
2397
+ error = (await result.json()).error;
2398
+ } catch (e) {}
2399
+ setState("error");
2400
+ options?.onError?.(error);
2371
2401
  return;
2372
2402
  }
2373
- history?.[pushOrReplace](finalPath === "" ? "/" : finalPath);
2374
- };
2375
- }
2376
- return {
2377
- push: action("push"),
2378
- replace: action("replace")
2403
+ const json = await result.json();
2404
+ clearPrefetchCache?.();
2405
+ options?.onSuccess?.(json);
2406
+ return json;
2407
+ } catch (error) {
2408
+ setState("error");
2409
+ options?.onError?.(error);
2410
+ return;
2411
+ }
2412
+ };
2413
+ return {
2414
+ state,
2415
+ progress,
2416
+ trigger,
2417
+ cancel
2418
+ };
2419
+ }
2420
+ //#endregion
2421
+ //#region client/useMutate.ts
2422
+ function useMutate() {
2423
+ const { getResource } = useContext(QueryManagerContext);
2424
+ return function mutate(options, fn) {
2425
+ const { path, params = {}, search = {} } = options ?? {};
2426
+ const normalPath = applyParams$1(path, params);
2427
+ const resource = getResource(normalPath);
2428
+ const searchParams = new URLSearchParams(omitNullishValues(search));
2429
+ searchParams.sort();
2430
+ const variantKey = searchParams.toString();
2431
+ return resource.mutate.call(resource, variantKey, (data) => {
2432
+ if (data === void 0 || data === null) {
2433
+ console.warn("Mutate function called before the query.");
2434
+ return data;
2435
+ }
2436
+ if (!fn) return data;
2437
+ const updatedData = typeof fn === "function" ? fn(data) : fn;
2438
+ if (isPlainObject(data)) {
2439
+ if (isPlainObject(updatedData)) return updatedData;
2440
+ throw new Error("Mutate function must return an object when the current data is an object.");
2441
+ }
2442
+ if (Array.isArray(data)) {
2443
+ if (Array.isArray(updatedData)) return updatedData;
2444
+ throw new Error("Mutate function must return an array when the current data is an array.");
2445
+ }
2446
+ if (typeof data !== typeof updatedData) throw new Error("Mutate function must return the same type as the current data.");
2447
+ return updatedData;
2448
+ });
2379
2449
  };
2380
2450
  }
2381
2451
  //#endregion
2382
- //#region client/useSearchParams.ts
2383
- var SearchParams = class {
2384
- searchParams;
2385
- callback;
2386
- constructor(searchParams, callback) {
2387
- this.searchParams = searchParams;
2388
- this.callback = callback;
2389
- }
2390
- get(key) {
2391
- return this.searchParams.get(key);
2392
- }
2393
- set(key, value) {
2394
- let entries = {};
2395
- if (typeof key === "string") {
2396
- let _value = value;
2397
- if (typeof value === "function") _value = value(this.get(key) ?? "");
2398
- entries[key] = _value;
2399
- } else entries = key ?? {};
2400
- for (const [key, value] of Object.entries(entries)) {
2401
- let _value = value;
2402
- if (typeof value === "function") _value = value(this.get(key) ?? "");
2403
- this.searchParams.set(key, _value);
2452
+ //#region client/Mutation.tsx
2453
+ var MutationContext = createContext({
2454
+ isPending: false,
2455
+ result: null
2456
+ });
2457
+ function Form(props) {
2458
+ const _params = useParams();
2459
+ const { method = "POST", action, onSuccess = () => {}, onError = () => {}, params, search = {}, className, dynamicInputs = () => ({}), ...formProps } = "params" in props ? {
2460
+ ...props,
2461
+ params: {
2462
+ ..._params,
2463
+ ...props.params
2404
2464
  }
2405
- return this;
2406
- }
2407
- append(key, value) {
2408
- this.searchParams.append(key, value);
2409
- return this;
2410
- }
2411
- sort() {
2412
- this.searchParams.sort();
2413
- return this;
2414
- }
2415
- clear() {
2416
- this.searchParams = new URLSearchParams();
2417
- return this;
2418
- }
2419
- delete(key) {
2420
- const keys = Array.isArray(key) ? key : [key];
2421
- for (const key of keys) this.searchParams.delete(key);
2422
- return this;
2423
- }
2424
- toJSON() {
2425
- const map = /* @__PURE__ */ new Map();
2426
- for (const [key, value] of this.searchParams) if (map.has(key)) {
2427
- const currentValue = map.get(key);
2428
- if (Array.isArray(currentValue)) {
2429
- currentValue.push(value);
2430
- map.set(key, currentValue);
2431
- } else map.set(key, [currentValue, value]);
2432
- } else map.set(key, value);
2433
- return Object.fromEntries(map.entries());
2434
- }
2435
- toString() {
2436
- return this.searchParams.toString();
2437
- }
2438
- push(mode = "soft") {
2439
- this.callback(this.toJSON(), mode === "soft");
2440
- }
2441
- };
2442
- function useSearchParams() {
2443
- const { push } = useNavigate();
2444
- const { search, pathname } = useContext(RouteStateContext);
2445
- const params = useParams();
2446
- const callback = (search, shallow) => {
2447
- push(pathname, {
2448
- params,
2449
- search,
2450
- shallow
2465
+ } : {
2466
+ ...props,
2467
+ params: _params
2468
+ };
2469
+ const formRef = useRef(null);
2470
+ const { __csrf } = useContext(ServerDataContext);
2471
+ const formDataSubject = useRef(new Subject(new FormData()));
2472
+ const updateFormData = useCallback(() => {
2473
+ formDataSubject.current.next(new FormData(formRef.current));
2474
+ }, []);
2475
+ useEffect(() => {
2476
+ if (!formRef.current) return;
2477
+ formRef.current.addEventListener("input", updateFormData);
2478
+ const observer = new MutationObserver(() => {
2479
+ const formData = new FormData(formRef.current);
2480
+ formDataSubject.current.next(formData);
2451
2481
  });
2482
+ formRef.current.querySelectorAll("input").forEach((input) => observer.observe(input, {
2483
+ attributes: true,
2484
+ attributeFilter: ["value"]
2485
+ }));
2486
+ formRef.current.querySelectorAll("select").forEach((input) => observer.observe(input, {
2487
+ attributes: true,
2488
+ attributeFilter: ["value"]
2489
+ }));
2490
+ formRef.current.querySelectorAll("textarea").forEach((input) => observer.observe(input, {
2491
+ attributes: true,
2492
+ attributeFilter: ["value"]
2493
+ }));
2494
+ return () => {
2495
+ observer.disconnect();
2496
+ if (formRef.current) formRef.current.removeEventListener("input", updateFormData);
2497
+ };
2498
+ }, [updateFormData]);
2499
+ const { trigger, data, error, loading } = useMutation(method, String(action), {
2500
+ params,
2501
+ search
2502
+ }, {
2503
+ onSuccess: (data) => onSuccess(data, formRef.current),
2504
+ onError: (error) => onError(error, formRef.current)
2505
+ });
2506
+ const handleSubmit = async (e) => {
2507
+ if (loading) return;
2508
+ e.preventDefault();
2509
+ if (!formRef.current) return;
2510
+ const formData = new FormData(formRef.current);
2511
+ for (const [key, value] of Object.entries(dynamicInputs(formData))) formData.append(key, value);
2512
+ trigger(formData);
2513
+ };
2514
+ const validationErrors = error?.kind === "validation_error" ? error.messages : {};
2515
+ const formError = error?.kind === "form_error" ? error.message : null;
2516
+ return /* @__PURE__ */ jsx(MutationContext.Provider, {
2517
+ value: {
2518
+ isPending: loading,
2519
+ result: data,
2520
+ validationErrors,
2521
+ formError,
2522
+ formDataSubject
2523
+ },
2524
+ children: /* @__PURE__ */ jsxs("form", {
2525
+ className: ["group", className].filter(Boolean).join(" "),
2526
+ "data-loading": loading,
2527
+ ref: formRef,
2528
+ onSubmit: handleSubmit,
2529
+ ...formProps,
2530
+ children: [/* @__PURE__ */ jsx("input", {
2531
+ type: "hidden",
2532
+ name: "__csrf",
2533
+ value: __csrf
2534
+ }), props.children]
2535
+ })
2536
+ });
2537
+ }
2538
+ function useMutationStatus() {
2539
+ const { isPending } = useContext(MutationContext);
2540
+ return { isPending };
2541
+ }
2542
+ function useFormStatus() {
2543
+ const { isPending, validationErrors, formError } = useContext(MutationContext);
2544
+ return {
2545
+ isPending,
2546
+ validationErrors,
2547
+ formError
2452
2548
  };
2453
- return new SearchParams(new URLSearchParams(search), callback);
2454
2549
  }
2550
+ function useFormData() {
2551
+ const { formDataSubject } = useContext(MutationContext);
2552
+ return useSyncExternalStore(formDataSubject.current.subscribe.bind(formDataSubject.current), formDataSubject.current.getValue.bind(formDataSubject.current), formDataSubject.current.getValue.bind(formDataSubject.current));
2553
+ }
2554
+ var ValidationErrors = (props) => {
2555
+ const { render = (props) => /* @__PURE__ */ jsx("div", { ...props }), name } = props;
2556
+ const { validationErrors } = useContext(MutationContext);
2557
+ const Comp = render;
2558
+ if (validationErrors[name]?.length > 0) return /* @__PURE__ */ jsx(Fragment$1, { children: validationErrors[name].map((error) => {
2559
+ return /* @__PURE__ */ jsx(Comp, {
2560
+ className: props.className,
2561
+ children: error
2562
+ }, error);
2563
+ }) });
2564
+ return null;
2565
+ };
2566
+ var FormFieldContainer = (props) => {
2567
+ const { name, children, ...rest } = props;
2568
+ const { validationErrors } = useContext(MutationContext);
2569
+ return /* @__PURE__ */ jsx("div", {
2570
+ "data-has-error": (validationErrors[name] || []).length > 0,
2571
+ ...rest,
2572
+ children
2573
+ });
2574
+ };
2575
+ var FormError = (props) => {
2576
+ const { formError } = useContext(MutationContext);
2577
+ if (formError) return /* @__PURE__ */ jsx("div", {
2578
+ ...props,
2579
+ children: formError
2580
+ });
2581
+ return null;
2582
+ };
2455
2583
  //#endregion
2456
2584
  //#region client/useIsNavigationPending.ts
2457
2585
  function useIsNavigationPending() {
@@ -2472,6 +2600,64 @@ function useNavigationProgress() {
2472
2600
  return progress;
2473
2601
  }
2474
2602
  //#endregion
2603
+ //#region client/usePrefetch.ts
2604
+ /**
2605
+ * A prefetch spends the visitor's data on a page they may never open, so it
2606
+ * stands down when they have asked for less of that or the connection cannot
2607
+ * spare it. `navigator.connection` only exists in Chromium — everywhere else
2608
+ * there is nothing to go on and prefetching proceeds.
2609
+ */
2610
+ function connectionRefusesPrefetch() {
2611
+ const connection = navigator?.connection;
2612
+ if (!connection) return false;
2613
+ if (connection.saveData) return true;
2614
+ return ["slow-2g", "2g"].includes(connection.effectiveType);
2615
+ }
2616
+ /**
2617
+ * Warms a route ahead of the navigation to it: its page data, its stylesheets
2618
+ * and its component chunks. A navigation that lands on a prefetched route
2619
+ * renders from the cached payload instead of waiting on a request.
2620
+ *
2621
+ * The URL is built exactly the way `useNavigate` builds it — the prefetch is
2622
+ * only ever used by a navigation that asks for the same one.
2623
+ */
2624
+ function usePrefetch() {
2625
+ const { prefetchRoute } = useContext(ClientRouterContext);
2626
+ const { defaultLocale } = useContext(I18nContext);
2627
+ const location = useLocation();
2628
+ const currentPathname = location.pathname;
2629
+ const currentSearch = location.search;
2630
+ const currentLocale = location.locale;
2631
+ return useCallback(async (path, ...args) => {
2632
+ if (typeof window === "undefined" || !prefetchRoute) return;
2633
+ if (connectionRefusesPrefetch()) return;
2634
+ const [options = {}] = args;
2635
+ const { search = {}, params = {}, locale = null } = {
2636
+ params: {},
2637
+ search: {},
2638
+ locale: null,
2639
+ ...options
2640
+ };
2641
+ let localeSegment = locale ?? currentLocale;
2642
+ if (localeSegment === defaultLocale) localeSegment = "";
2643
+ const pathname = applyParams$1(path, params) || "/";
2644
+ const queryString = new URLSearchParams(search).toString();
2645
+ const searchSegment = queryString.length > 0 ? `?${queryString}` : "";
2646
+ if (pathname === currentPathname && searchSegment === currentSearch) return;
2647
+ await prefetchRoute({
2648
+ pathname,
2649
+ search: searchSegment,
2650
+ localeSegment: localeSegment ? `/${localeSegment}` : ""
2651
+ });
2652
+ }, [
2653
+ prefetchRoute,
2654
+ defaultLocale,
2655
+ currentLocale,
2656
+ currentPathname,
2657
+ currentSearch
2658
+ ]);
2659
+ }
2660
+ //#endregion
2475
2661
  //#region client/useBreadcrumbs.ts
2476
2662
  function useBreadcrumbs() {
2477
2663
  const { pathname } = useRoute();
@@ -2506,13 +2692,17 @@ function useRouteTransition() {
2506
2692
  }
2507
2693
  //#endregion
2508
2694
  //#region client/Link.tsx
2695
+ /** How long the pointer has to rest on an `intent` link before it counts. */
2696
+ var INTENT_DELAY = 100;
2697
+ /** How far ahead of the viewport a `viewport` link starts warming. */
2698
+ var VIEWPORT_MARGIN = "200px";
2509
2699
  function normalizeSearch(search) {
2510
2700
  return Object.fromEntries(Object.entries(search).filter(([_k, v]) => v !== void 0 && v !== null).map(([k, v]) => [k, String(v)]));
2511
2701
  }
2512
2702
  var Link = memo((props) => {
2513
2703
  const _params = useParams();
2514
2704
  const { isTransitioning, targetPath } = useRouteTransition();
2515
- const { href, onClick, hash = "", active = false, params = {}, search = {}, ...rest } = {
2705
+ const { href, onClick, onMouseEnter, onMouseLeave, onTouchStart, onFocus, onBlur, ref, hash = "", active = false, prefetch, params = {}, search = {}, ...rest } = {
2516
2706
  params: _params,
2517
2707
  search: {},
2518
2708
  ...props
@@ -2520,6 +2710,7 @@ var Link = memo((props) => {
2520
2710
  const { defaultLocale } = useContext(I18nContext);
2521
2711
  const { push } = useNavigate();
2522
2712
  const location = useLocation();
2713
+ const prefetchRoute = usePrefetch();
2523
2714
  const searchParams = new URLSearchParams(normalizeSearch(search));
2524
2715
  const path = applyParams$1(href, params);
2525
2716
  let urlLocaleSegment = location.locale;
@@ -2531,7 +2722,70 @@ var Link = memo((props) => {
2531
2722
  location.search,
2532
2723
  location.hash
2533
2724
  ].filter((item) => !!item).join("");
2725
+ const prefetchRouteRef = useRef(prefetchRoute);
2726
+ useEffect(() => {
2727
+ prefetchRouteRef.current = prefetchRoute;
2728
+ });
2729
+ const isShallowTarget = (path || "/") === location.pathname;
2730
+ const strategies = !prefetch ? [] : Array.isArray(prefetch) ? prefetch : [prefetch];
2731
+ const uses = (strategy) => strategies.includes(strategy);
2732
+ const strategyKey = strategies.join(",");
2733
+ const runPrefetch = () => {
2734
+ if (strategies.length === 0 || isShallowTarget) return;
2735
+ prefetchRouteRef.current(href, {
2736
+ params,
2737
+ search
2738
+ });
2739
+ };
2740
+ useEffect(() => {
2741
+ if (uses("render")) runPrefetch();
2742
+ }, [strategyKey, targetHref]);
2743
+ const anchorRef = useRef(null);
2744
+ const setAnchorRef = useCallback((node) => {
2745
+ anchorRef.current = node;
2746
+ if (typeof ref === "function") ref(node);
2747
+ else if (ref) ref.current = node;
2748
+ }, [ref]);
2749
+ useEffect(() => {
2750
+ if (!uses("viewport")) return;
2751
+ const element = anchorRef.current;
2752
+ if (!element || typeof IntersectionObserver === "undefined") return;
2753
+ const observer = new IntersectionObserver((entries) => {
2754
+ if (entries.some((entry) => entry.isIntersecting)) {
2755
+ observer.disconnect();
2756
+ runPrefetch();
2757
+ }
2758
+ }, { rootMargin: VIEWPORT_MARGIN });
2759
+ observer.observe(element);
2760
+ return () => observer.disconnect();
2761
+ }, [strategyKey, targetHref]);
2762
+ const intentTimerRef = useRef(null);
2763
+ const cancelIntent = () => {
2764
+ if (intentTimerRef.current !== null) {
2765
+ clearTimeout(intentTimerRef.current);
2766
+ intentTimerRef.current = null;
2767
+ }
2768
+ };
2769
+ useEffect(() => cancelIntent, []);
2770
+ /** A pointer arriving — the one signal `intent` waits on before believing. */
2771
+ const pointerArrived = () => {
2772
+ if (uses("hover")) runPrefetch();
2773
+ else if (uses("intent")) {
2774
+ cancelIntent();
2775
+ intentTimerRef.current = setTimeout(runPrefetch, INTENT_DELAY);
2776
+ }
2777
+ };
2778
+ /** Focus and touch are deliberate, so neither strategy makes them wait. */
2779
+ const linkTargeted = () => {
2780
+ if (uses("hover") || uses("intent")) runPrefetch();
2781
+ };
2782
+ /** Runs the caller's own handler first, then the prefetch trigger. */
2783
+ const prefetchOn = (handler, trigger) => (event) => {
2784
+ handler?.(event);
2785
+ trigger();
2786
+ };
2534
2787
  return /* @__PURE__ */ jsx("a", {
2788
+ ref: setAnchorRef,
2535
2789
  "data-active": active || currentHref === targetHref,
2536
2790
  "data-pending": href === targetPath && isTransitioning,
2537
2791
  href: targetHref === "" ? "/" : targetHref,
@@ -2546,7 +2800,6 @@ var Link = memo((props) => {
2546
2800
  currentPath = currentPath === "" ? "/" : currentPath;
2547
2801
  onClick?.(e);
2548
2802
  if (hash === "") e.preventDefault();
2549
- onClick?.(e);
2550
2803
  push(href, {
2551
2804
  hash,
2552
2805
  search,
@@ -2554,6 +2807,11 @@ var Link = memo((props) => {
2554
2807
  shallow: path === currentPath
2555
2808
  });
2556
2809
  },
2810
+ onMouseEnter: prefetchOn(onMouseEnter, pointerArrived),
2811
+ onMouseLeave: prefetchOn(onMouseLeave, cancelIntent),
2812
+ onTouchStart: prefetchOn(onTouchStart, linkTargeted),
2813
+ onFocus: prefetchOn(onFocus, linkTargeted),
2814
+ onBlur: prefetchOn(onBlur, cancelIntent),
2557
2815
  ...rest
2558
2816
  });
2559
2817
  });
@@ -2864,6 +3122,47 @@ function mergeCarriedSegments(previous, next, carriedViews) {
2864
3122
  };
2865
3123
  }
2866
3124
  //#endregion
3125
+ //#region client/helpers/loadRoutePayload.ts
3126
+ /**
3127
+ * The page data for a navigation, from whichever source can produce it.
3128
+ *
3129
+ * A prefetched payload is always a full render, so it can be committed as-is
3130
+ * and the `x-gemi-from` round trip skipped entirely. Everything else — no
3131
+ * prefetch, a prefetch that failed, a partial response computed against a route
3132
+ * that has since been navigated away from — falls through to a request.
3133
+ *
3134
+ * Returns `null` when nothing usable came back; the caller leaves the current
3135
+ * route on screen.
3136
+ */
3137
+ async function loadRoutePayload(options) {
3138
+ const { url, from, takePrefetched, renderedRoute } = options;
3139
+ const prefetched = takePrefetched?.(url);
3140
+ if (prefetched) {
3141
+ const payload = await prefetched;
3142
+ if (payload) return payload;
3143
+ }
3144
+ let response = {
3145
+ ok: false,
3146
+ json: async () => ({})
3147
+ };
3148
+ try {
3149
+ response = await fetch(url, { headers: { [PARTIAL_RENDER_HEADER]: from } });
3150
+ } catch (e) {
3151
+ console.error(e);
3152
+ return null;
3153
+ }
3154
+ if (!response.ok) return null;
3155
+ const payload = await response.json();
3156
+ const claimed = payload?.partial ?? null;
3157
+ if (claimed && claimed.from !== renderedRoute()) try {
3158
+ const full = await fetch(url);
3159
+ if (full.ok) return await full.json();
3160
+ } catch (e) {
3161
+ console.error(e);
3162
+ }
3163
+ return payload;
3164
+ }
3165
+ //#endregion
2867
3166
  //#region client/ClientRouter.tsx
2868
3167
  function restoreScroll(action = null, _pathname = "no path") {
2869
3168
  if (action === null) return;
@@ -2929,7 +3228,7 @@ var Routes = (props) => {
2929
3228
  const { componentTree } = props;
2930
3229
  const [isPending, startTransition] = useTransition();
2931
3230
  const [isFetching, setIsFetching] = useState(false);
2932
- const { routerSubject, fetchRouteCSS } = useContext(ClientRouterContext);
3231
+ const { routerSubject, fetchRouteCSS, takePrefetched } = useContext(ClientRouterContext);
2933
3232
  const { hydrate } = useContext(QueryManagerContext);
2934
3233
  const [transitionPath, setTransitionPath] = useState([null, routerSubject?.getValue().pathname]);
2935
3234
  const { breadcrumbs, pageData, i18n, prefetchedData, appId: currentAppId } = useContext(ServerDataContext);
@@ -2975,35 +3274,22 @@ var Routes = (props) => {
2975
3274
  }));
2976
3275
  return;
2977
3276
  }
2978
- const localeSegment = routerState.locale ? `/${routerState.locale}` : "";
2979
- const url = `${`${localeSegment}${localeSegment.length > 0 && pathname === "/" ? "" : pathname}`}.json${search}`;
3277
+ const url = routeDataUrl({
3278
+ pathname,
3279
+ search,
3280
+ localeSegment: routerState.locale ? `/${routerState.locale}` : ""
3281
+ });
2980
3282
  const from = renderedRouteRef.current;
2981
3283
  setIsFetching(true);
2982
- let res = {
2983
- ok: false,
2984
- json: async () => ({})
2985
- };
2986
- try {
2987
- res = (await Promise.all([
2988
- fetch(url, { headers: { [PARTIAL_RENDER_HEADER]: from } }),
2989
- fetchRouteCSS(pathname),
2990
- ...views.map((component) => {
2991
- if (!window?.loaders) return Promise.resolve();
2992
- (window?.loaders?.[component] ?? (() => ({})))();
2993
- })
2994
- ]))[0];
2995
- } catch (e) {
2996
- console.error(e);
2997
- }
2998
- if (res.ok) {
2999
- let payload = await res.json();
3000
- const claimed = payload.partial ?? null;
3001
- if (claimed && claimed.from !== renderedRouteRef.current) try {
3002
- const full = await fetch(url);
3003
- if (full.ok) payload = await full.json();
3004
- } catch (e) {
3005
- console.error(e);
3006
- }
3284
+ fetchRouteCSS(routerState.routePath).catch((e) => console.error(e));
3285
+ for (const component of views) window?.loaders?.[component]?.();
3286
+ const payload = await loadRoutePayload({
3287
+ url,
3288
+ from,
3289
+ takePrefetched,
3290
+ renderedRoute: () => renderedRouteRef.current
3291
+ });
3292
+ if (payload) {
3007
3293
  const { data, i18n, prefetchedData, breadcrumbs, meta, directive = {}, is404 = false, appId } = payload;
3008
3294
  updateMeta(meta);
3009
3295
  if (directive?.kind === "Redirect") {
@@ -3040,6 +3326,7 @@ var Routes = (props) => {
3040
3326
  }, [
3041
3327
  routerSubject,
3042
3328
  fetchRouteCSS,
3329
+ takePrefetched,
3043
3330
  replace,
3044
3331
  hydrate
3045
3332
  ]);
@@ -3435,6 +3722,6 @@ function useAppIdMissmatch() {
3435
3722
  return current !== next;
3436
3723
  }
3437
3724
  //#endregion
3438
- export { Form, FormError, FormFieldContainer, Head, Image, Link, OpenGraphImage, QueryManagerProvider, Redirect, ValidationErrors, create, createRoot, init, useAppIdMissmatch, useBreadcrumbs, useBroadcast, useDelete, useForgotPassword, useFormData, useFormStatus, useIsNavigationPending, useLocale, useLocation, useMutate, useMutation, useMutationStatus, useNavigate, useNavigationProgress, useParams, usePatch, usePost, usePut, useQuery, useResetPassword, useRoute, useRouteTransition, useSearchParams, useSignIn, useSignOut, useSignUp, useSubscription, useTheme, useTranslator, useUpload, useUser };
3725
+ export { Form, FormError, FormFieldContainer, Head, Image, Link, OpenGraphImage, QueryManagerProvider, Redirect, ValidationErrors, create, createRoot, init, useAppIdMissmatch, useBreadcrumbs, useBroadcast, useDelete, useForgotPassword, useFormData, useFormStatus, useIsNavigationPending, useLocale, useLocation, useMutate, useMutation, useMutationStatus, useNavigate, useNavigationProgress, useParams, usePatch, usePost, usePrefetch, usePut, useQuery, useResetPassword, useRoute, useRouteTransition, useSearchParams, useSignIn, useSignOut, useSignUp, useSubscription, useTheme, useTranslator, useUpload, useUser };
3439
3726
 
3440
3727
  //# sourceMappingURL=index.js.map