@appfunnel-dev/sdk 2.0.0-canary.4 → 2.0.0-canary.5

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.
Files changed (53) hide show
  1. package/dist/{chunk-AKO6XKXP.js → chunk-AXXG3ODP.js} +10 -3
  2. package/dist/chunk-AXXG3ODP.js.map +1 -0
  3. package/dist/{chunk-M6U3FNRW.js → chunk-CFFMZYPE.js} +3 -3
  4. package/dist/{chunk-M6U3FNRW.js.map → chunk-CFFMZYPE.js.map} +1 -1
  5. package/dist/chunk-EMMSS5I5.cjs +37 -0
  6. package/dist/chunk-EMMSS5I5.cjs.map +1 -0
  7. package/dist/chunk-G3PMV62Z.js +33 -0
  8. package/dist/chunk-G3PMV62Z.js.map +1 -0
  9. package/dist/{chunk-JSRKA375.cjs → chunk-G44BUG6G.cjs} +10 -3
  10. package/dist/chunk-G44BUG6G.cjs.map +1 -0
  11. package/dist/{chunk-LJYLGLFS.cjs → chunk-QMAZGLGV.cjs} +2 -35
  12. package/dist/chunk-QMAZGLGV.cjs.map +1 -0
  13. package/dist/{chunk-YY375F2B.js → chunk-TNLRQPVY.js} +3 -33
  14. package/dist/chunk-TNLRQPVY.js.map +1 -0
  15. package/dist/{chunk-CY4VBSMX.cjs → chunk-VAOR77NW.cjs} +7 -7
  16. package/dist/{chunk-CY4VBSMX.cjs.map → chunk-VAOR77NW.cjs.map} +1 -1
  17. package/dist/chunk-WYUDL4FI.cjs +8 -0
  18. package/dist/chunk-WYUDL4FI.cjs.map +1 -0
  19. package/dist/chunk-ZZJG4EYL.js +6 -0
  20. package/dist/chunk-ZZJG4EYL.js.map +1 -0
  21. package/dist/driver-paddle.cjs +23 -22
  22. package/dist/driver-paddle.cjs.map +1 -1
  23. package/dist/driver-paddle.js +3 -2
  24. package/dist/driver-paddle.js.map +1 -1
  25. package/dist/driver-stripe.cjs +37 -36
  26. package/dist/driver-stripe.cjs.map +1 -1
  27. package/dist/driver-stripe.js +3 -2
  28. package/dist/driver-stripe.js.map +1 -1
  29. package/dist/index.cjs +504 -80
  30. package/dist/index.cjs.map +1 -1
  31. package/dist/index.d.cts +135 -74
  32. package/dist/index.d.ts +135 -74
  33. package/dist/index.js +447 -23
  34. package/dist/index.js.map +1 -1
  35. package/dist/{manifest-Cr2y1op6.d.cts → manifest-Dl7faaKe.d.cts} +34 -0
  36. package/dist/{manifest-Cr2y1op6.d.ts → manifest-Dl7faaKe.d.ts} +34 -0
  37. package/dist/manifest-entry.cjs +45 -30
  38. package/dist/manifest-entry.cjs.map +1 -1
  39. package/dist/manifest-entry.d.cts +1 -1
  40. package/dist/manifest-entry.d.ts +1 -1
  41. package/dist/manifest-entry.js +10 -3
  42. package/dist/manifest-entry.js.map +1 -1
  43. package/dist/protocol.cjs +13 -0
  44. package/dist/protocol.cjs.map +1 -0
  45. package/dist/protocol.d.cts +182 -0
  46. package/dist/protocol.d.ts +182 -0
  47. package/dist/protocol.js +4 -0
  48. package/dist/protocol.js.map +1 -0
  49. package/package.json +14 -2
  50. package/dist/chunk-AKO6XKXP.js.map +0 -1
  51. package/dist/chunk-JSRKA375.cjs.map +0 -1
  52. package/dist/chunk-LJYLGLFS.cjs.map +0 -1
  53. package/dist/chunk-YY375F2B.js.map +0 -1
package/dist/index.js CHANGED
@@ -1,7 +1,9 @@
1
- import { isRtl, formatProduct, bucketingSeed, resolveExperiments, entryGuard, evaluateGate, expectedPathLength, nextPage, outgoingKeys, buildCatalog, resolveLocale } from './chunk-AKO6XKXP.js';
2
- export { assignVariant, bucketingSeed, buildCatalog, compileManifest, currencyExponent, defineFunnel, definePage, entryGuard, evaluateCondition, evaluateGate, expectedPathLength, fnv1a, formatMoney, formatProduct, hashToUnit, isRtl, isVariantKey, nextPage, outgoingKeys, pageMeta, parseSlotKey, pickByWeight, resolveExperiments, resolveLocale, resolveProduct, resolveRoute, validateExperiments } from './chunk-AKO6XKXP.js';
3
- import { checkoutError, isInlineSurface, PROVIDER_PROFILES } from './chunk-YY375F2B.js';
4
- export { INLINE_SURFACES, PROVIDER_PROFILES, checkoutError, isInlineSurface, isMerchantOfRecord, isOrchestrator, surfacesFor, validateCheckout, validateUpsell } from './chunk-YY375F2B.js';
1
+ import { isRtl, formatProduct, bucketingSeed, resolveExperiments, entryGuard, evaluateGate, expectedPathLength, nextPage, outgoingKeys, buildCatalog, resolveLocale } from './chunk-AXXG3ODP.js';
2
+ export { assignVariant, bucketingSeed, buildCatalog, compileManifest, currencyExponent, defineFunnel, definePage, entryGuard, evaluateCondition, evaluateGate, expectedPathLength, fnv1a, formatMoney, formatProduct, hashToUnit, isRtl, isVariantKey, nextPage, outgoingKeys, pageMeta, parseSlotKey, pickByWeight, resolveExperiments, resolveLocale, resolveProduct, resolveRoute, validateExperiments } from './chunk-AXXG3ODP.js';
3
+ import { AF_PROTOCOL } from './chunk-ZZJG4EYL.js';
4
+ import { checkoutError, isInlineSurface, PROVIDER_PROFILES } from './chunk-TNLRQPVY.js';
5
+ export { INLINE_SURFACES, PROVIDER_PROFILES, checkoutError, isInlineSurface, isMerchantOfRecord, isOrchestrator, surfacesFor, validateCheckout, validateUpsell } from './chunk-TNLRQPVY.js';
6
+ import './chunk-G3PMV62Z.js';
5
7
  import React, { createContext, createElement, useCallback, useContext, useMemo, lazy, useRef, useState, useEffect, useTransition, Suspense, Fragment, isValidElement, cloneElement, useReducer, useSyncExternalStore } from 'react';
6
8
  import ReactDOM, { createPortal } from 'react-dom';
7
9
  import { jsx } from 'react/jsx-runtime';
@@ -380,7 +382,27 @@ function createBus(accessors, now2 = () => Date.now()) {
380
382
  }
381
383
  }
382
384
  };
383
- return { on, off, events, emit, ...accessors };
385
+ let nav = null;
386
+ let warnedNoNav = false;
387
+ const currentNav = () => {
388
+ if (!nav && !warnedNoNav) {
389
+ warnedNoNav = true;
390
+ console.warn("[appfunnel] window.appfunnel navigation called before the funnel flow mounted \u2014 ignored");
391
+ }
392
+ return nav;
393
+ };
394
+ const registerNavigation = (n) => {
395
+ nav = n;
396
+ warnedNoNav = false;
397
+ return () => {
398
+ if (nav === n) nav = null;
399
+ };
400
+ };
401
+ const goTo = (pageKey) => currentNav()?.goTo(pageKey) ?? false;
402
+ const goNext = () => currentNav()?.goNext();
403
+ const goPrevious = () => currentNav()?.goPrevious();
404
+ const navigation = () => nav?.snapshot() ?? null;
405
+ return { on, off, events, emit, registerNavigation, goTo, goNext, goPrevious, navigation, ...accessors };
384
406
  }
385
407
  function withBus(tracker, bus) {
386
408
  return {
@@ -402,7 +424,19 @@ function withBus(tracker, bus) {
402
424
  function attachBus(bus) {
403
425
  if (typeof window === "undefined") return () => {
404
426
  };
405
- const { on, off, getVariable, getVariables, getCurrentPageId, getCustomerId, getVisitorId } = bus;
427
+ const {
428
+ on,
429
+ off,
430
+ getVariable,
431
+ getVariables,
432
+ getCurrentPageId,
433
+ getCustomerId,
434
+ getVisitorId,
435
+ goTo,
436
+ goNext,
437
+ goPrevious,
438
+ navigation
439
+ } = bus;
406
440
  const api = {
407
441
  on,
408
442
  off,
@@ -411,6 +445,11 @@ function attachBus(bus) {
411
445
  getCurrentPageId,
412
446
  getCustomerId,
413
447
  getVisitorId,
448
+ // Flow control (bound closures over the bus's live nav registration).
449
+ goTo,
450
+ goNext,
451
+ goPrevious,
452
+ navigation,
414
453
  get events() {
415
454
  return bus.events;
416
455
  }
@@ -552,6 +591,102 @@ function useProducts() {
552
591
  [catalog, locale]
553
592
  );
554
593
  }
594
+
595
+ // src/flow/screenshot.ts
596
+ var MIME = { png: "image/png", jpeg: "image/jpeg", webp: "image/webp" };
597
+ var ScreenshotError = class extends Error {
598
+ constructor(code, message) {
599
+ super(message);
600
+ this.code = code;
601
+ }
602
+ };
603
+ function resolveTarget(t) {
604
+ if (!t || t.kind === "page") return document.getElementById("root") ?? document.body;
605
+ if (t.kind === "viewport") return document.body;
606
+ return document.querySelector(t.selector);
607
+ }
608
+ var delay = (ms) => new Promise((r) => setTimeout(r, ms));
609
+ function hideAll(selectors) {
610
+ const touched = [];
611
+ for (const sel of selectors) {
612
+ document.querySelectorAll(sel).forEach((el) => {
613
+ touched.push([el, el.style.visibility]);
614
+ el.style.visibility = "hidden";
615
+ });
616
+ }
617
+ return () => touched.forEach(([el, v]) => el.style.visibility = v);
618
+ }
619
+ async function scrollThrough(el) {
620
+ const prev = el.scrollTop;
621
+ const step = el.clientHeight || window.innerHeight;
622
+ for (let y = 0; y < el.scrollHeight; y += step) {
623
+ el.scrollTop = y;
624
+ await delay(16);
625
+ }
626
+ el.scrollTop = prev;
627
+ await delay(16);
628
+ }
629
+ function blobToDataURL(blob) {
630
+ return new Promise((resolve, reject) => {
631
+ const fr = new FileReader();
632
+ fr.onload = () => resolve(String(fr.result));
633
+ fr.onerror = () => reject(fr.error);
634
+ fr.readAsDataURL(blob);
635
+ });
636
+ }
637
+ async function captureScreenshot(params = {}) {
638
+ const el = resolveTarget(params.target);
639
+ if (!el) throw new ScreenshotError("bad_params", "screenshot target not found");
640
+ const restore = params.hideSelectors?.length ? hideAll(params.hideSelectors) : null;
641
+ const scale = Math.min(params.scale ?? 2, params.maxScale ?? 3);
642
+ const format = params.format ?? "png";
643
+ const timeout = params.timeout ?? 1e4;
644
+ try {
645
+ if (params.waitForFonts !== false && document.fonts?.ready) {
646
+ await Promise.race([document.fonts.ready, delay(timeout)]);
647
+ }
648
+ if (params.triggerLazyLoad) await scrollThrough(el);
649
+ if (params.waitForImages !== false) {
650
+ await Promise.all(
651
+ Array.from(el.querySelectorAll("img")).map(
652
+ (img) => typeof img.decode === "function" ? img.decode().catch(() => {
653
+ }) : Promise.resolve()
654
+ )
655
+ );
656
+ }
657
+ const { domToBlob } = await import('modern-screenshot');
658
+ let blob;
659
+ try {
660
+ blob = await domToBlob(el, {
661
+ type: MIME[format],
662
+ quality: params.quality ?? 0.92,
663
+ scale,
664
+ width: params.width,
665
+ height: params.height,
666
+ // `null` = transparent (png/webp); only fall back to white when the caller omitted it. `??`
667
+ // would wrongly collapse an explicit `null` to white.
668
+ backgroundColor: params.backgroundColor === void 0 ? "#ffffff" : params.backgroundColor,
669
+ timeout
670
+ });
671
+ } catch (e) {
672
+ throw new ScreenshotError("failed", `capture failed (an image/font may be served without CORS '*'): ${String(e?.message || e)}`);
673
+ }
674
+ const result = {
675
+ mime: MIME[format],
676
+ width: Math.round((params.width ?? el.clientWidth) * scale),
677
+ height: Math.round((params.height ?? el.scrollHeight) * scale),
678
+ scale,
679
+ bytesApprox: blob.size
680
+ };
681
+ if ((params.encoding ?? "blob") === "dataURL") result.dataURL = await blobToDataURL(blob);
682
+ else result.blob = blob;
683
+ return result;
684
+ } finally {
685
+ restore?.();
686
+ }
687
+ }
688
+
689
+ // src/flow/flow.tsx
555
690
  var NavContext = createContext(null);
556
691
  var ExperimentContext = createContext({});
557
692
  function useExperiment(id) {
@@ -604,6 +739,7 @@ function usePageExitTimer(tracker, currentKey) {
604
739
  function FunnelView({
605
740
  pages,
606
741
  initialKey,
742
+ trustInitialKey = false,
607
743
  layout,
608
744
  fallback,
609
745
  prefetch: prefetchMode = "auto",
@@ -651,6 +787,7 @@ function FunnelView({
651
787
  if (!initialKey || initialKey === start) return start ? [start] : [];
652
788
  const target = toSlot(initialKey);
653
789
  const page2 = flowPages.find((p) => p.key === target);
790
+ if (trustInitialKey) return page2 ? [target] : start ? [start] : [];
654
791
  const guard = entryGuard(page2?.meta);
655
792
  const ok = page2 && (!guard || evaluateGate(guard, funnel.snapshot()));
656
793
  const first = ok ? target : start;
@@ -736,6 +873,44 @@ function FunnelView({
736
873
  canGoBack: history.length > 1,
737
874
  isNavigating
738
875
  };
876
+ const bus = useBusInternal();
877
+ const navRef = useRef(value);
878
+ navRef.current = value;
879
+ useEffect(() => {
880
+ if (!bus) return;
881
+ const control = {
882
+ goTo: (key) => {
883
+ if (!components.has(toRenderKey(key))) return false;
884
+ navRef.current.go(key);
885
+ return true;
886
+ },
887
+ goNext: () => navRef.current.next(),
888
+ goPrevious: () => navRef.current.back(),
889
+ snapshot: () => {
890
+ const v = navRef.current;
891
+ return {
892
+ key: v.page.key,
893
+ index: v.page.index,
894
+ total: v.page.total,
895
+ progressPercentage: v.page.progressPercentage,
896
+ canGoBack: v.canGoBack,
897
+ nextCandidates: v.nextCandidates
898
+ };
899
+ }
900
+ };
901
+ return bus.registerNavigation(control);
902
+ }, [bus]);
903
+ useEffect(() => {
904
+ const v = navRef.current;
905
+ bus?.emit("navigation", {
906
+ key: v.page.key,
907
+ index: v.page.index,
908
+ total: v.page.total,
909
+ progressPercentage: v.page.progressPercentage,
910
+ canGoBack: v.canGoBack,
911
+ nextCandidates: v.nextCandidates
912
+ });
913
+ }, [bus, currentKey]);
739
914
  const renderKey = toRenderKey(currentKey);
740
915
  const Current = components.get(renderKey);
741
916
  const pageEl = createElement(Suspense, { fallback: fallback ?? null }, Current ? createElement(Current) : null);
@@ -751,6 +926,81 @@ function useNavigation() {
751
926
  if (!ctx) throw new Error("useNavigation must be used inside <FunnelView>");
752
927
  return ctx;
753
928
  }
929
+ var EMBED_CAPABILITIES = {
930
+ methods: ["nav.snapshot", "nav.goTo", "nav.goNext", "nav.goPrevious", "screenshot"],
931
+ events: ["navigation"]
932
+ };
933
+ function EmbedBridge() {
934
+ const bus = useBusInternal();
935
+ useEffect(() => {
936
+ if (!bus || typeof window === "undefined" || window.parent === window) return;
937
+ const parent = window.parent;
938
+ let disposed = false;
939
+ const send = (msg) => {
940
+ try {
941
+ parent.postMessage({ af: AF_PROTOCOL, source: "af-preview", ...msg }, "*");
942
+ } catch {
943
+ }
944
+ };
945
+ const hello = () => send({ kind: "hello", role: "funnel", protocol: AF_PROTOCOL, capabilities: EMBED_CAPABILITIES });
946
+ const sendSnapshot = () => {
947
+ const s = bus.navigation();
948
+ if (s) send({ kind: "event", event: "navigation", data: s });
949
+ };
950
+ async function invoke(method, params) {
951
+ switch (method) {
952
+ case "nav.snapshot":
953
+ return bus.navigation();
954
+ case "nav.goTo": {
955
+ const key = params?.key;
956
+ if (typeof key !== "string" || !bus.goTo(key)) throw { code: "bad_params", message: `unknown page key: ${String(key)}` };
957
+ return void 0;
958
+ }
959
+ case "nav.goNext":
960
+ bus.goNext();
961
+ return void 0;
962
+ case "nav.goPrevious":
963
+ bus.goPrevious();
964
+ return void 0;
965
+ case "screenshot":
966
+ return await captureScreenshot(params ?? {});
967
+ default:
968
+ throw { code: "unknown_method", message: `unknown method: ${method}` };
969
+ }
970
+ }
971
+ async function onMsg(e) {
972
+ if (e.source !== parent) return;
973
+ const d = e.data;
974
+ if (!d || typeof d !== "object" || d.af !== AF_PROTOCOL || d.source !== "af-host") return;
975
+ if (d.kind === "hello") {
976
+ hello();
977
+ sendSnapshot();
978
+ return;
979
+ }
980
+ if (d.kind !== "request" || typeof d.id !== "string") return;
981
+ try {
982
+ const result = await invoke(d.method, d.params);
983
+ if (!disposed) send({ kind: "response", id: d.id, ok: true, result });
984
+ } catch (err) {
985
+ const e2 = err;
986
+ const error = e2 && typeof e2.code === "string" ? { code: e2.code, message: String(e2.message ?? e2.code) } : { code: "failed", message: String(e2?.message ?? err) };
987
+ if (!disposed) send({ kind: "response", id: d.id, ok: false, error });
988
+ }
989
+ }
990
+ window.addEventListener("message", onMsg);
991
+ const offNav = bus.on("navigation", (data) => {
992
+ if (!disposed) send({ kind: "event", event: "navigation", data });
993
+ });
994
+ hello();
995
+ sendSnapshot();
996
+ return () => {
997
+ disposed = true;
998
+ offNav();
999
+ window.removeEventListener("message", onMsg);
1000
+ };
1001
+ }, [bus]);
1002
+ return null;
1003
+ }
754
1004
  function usePage() {
755
1005
  return useNavigation().page;
756
1006
  }
@@ -2568,6 +2818,7 @@ var FunnelCtx = createContext(null);
2568
2818
  function FunnelProvider({
2569
2819
  config,
2570
2820
  sessionValues,
2821
+ deferSessionValues = false,
2571
2822
  tracker,
2572
2823
  context: contextOpts,
2573
2824
  products,
@@ -2583,7 +2834,12 @@ function FunnelProvider({
2583
2834
  const [value] = useState(() => {
2584
2835
  const s = store ?? createFunnelStore(
2585
2836
  { responses: config?.responses, data: config?.data },
2586
- sessionValues
2837
+ // FLASH-FREE HYDRATION: with deferSessionValues the initial render is seeded with
2838
+ // DEFAULTS ONLY, so the client's first render is byte-identical to the build-time
2839
+ // DEFAULT-state prerender it hydrates against (clean match). The visitor's saved
2840
+ // answers arrive as a post-hydration setMany below. Without the flag, sessionValues
2841
+ // seed the store at construction (SSR + non-prerendered paths).
2842
+ deferSessionValues ? void 0 : sessionValues
2587
2843
  );
2588
2844
  const ctx = buildContext({
2589
2845
  funnelId: config?.id,
@@ -2615,6 +2871,12 @@ function FunnelProvider({
2615
2871
  };
2616
2872
  });
2617
2873
  useEffect(() => attachBus(value.bus), [value.bus]);
2874
+ useEffect(() => {
2875
+ if (!deferSessionValues || !sessionValues) return;
2876
+ const updates = {};
2877
+ for (const [key, v] of Object.entries(sessionValues)) if (v !== void 0) updates[key] = v;
2878
+ if (Object.keys(updates).length > 0) value.store.setMany(updates);
2879
+ }, []);
2618
2880
  useEffect(() => {
2619
2881
  value.tracker.setAcquisition?.(buildAcquisition(value.context));
2620
2882
  }, [value]);
@@ -2650,6 +2912,9 @@ function useFunnelCtx() {
2650
2912
  function useFunnel() {
2651
2913
  return useFunnelCtx().funnel;
2652
2914
  }
2915
+ function useBusInternal() {
2916
+ return useContext(FunnelCtx)?.bus ?? null;
2917
+ }
2653
2918
  function useStoreKey(store, key) {
2654
2919
  const subscribe = useCallback(
2655
2920
  (cb) => store.subscribe(cb, { keys: [key] }),
@@ -2713,6 +2978,152 @@ function useContextValue(path) {
2713
2978
  );
2714
2979
  }
2715
2980
 
2981
+ // src/tracking/snapshotPersistence.ts
2982
+ function createSnapshotPersistence(opts) {
2983
+ const baseDelay = opts.baseDelayMs ?? 1e3;
2984
+ const maxDelay = opts.maxDelayMs ?? 3e4;
2985
+ const setT = opts.timers?.setTimeout ?? ((fn, ms) => setTimeout(fn, ms));
2986
+ const clearT = opts.timers?.clearTimeout ?? ((h) => clearTimeout(h));
2987
+ const rand = opts.random ?? Math.random;
2988
+ let pending = null;
2989
+ let seq = 0;
2990
+ let pendingSeq = 0;
2991
+ let inFlight = false;
2992
+ let attempt = 0;
2993
+ let retryTimer = null;
2994
+ let disposed = false;
2995
+ let fatalSeq = -1;
2996
+ const clearRetry = () => {
2997
+ if (retryTimer !== null) {
2998
+ clearT(retryTimer);
2999
+ retryTimer = null;
3000
+ }
3001
+ };
3002
+ const buildBody = (snapshot, s) => JSON.stringify({ sessionId: opts.getSessionId(), userData: snapshot, seq: s });
3003
+ const trySend = () => {
3004
+ if (disposed || inFlight || pending === null) return;
3005
+ if (retryTimer !== null) return;
3006
+ const sessionId = opts.getSessionId();
3007
+ if (!sessionId) return;
3008
+ if (typeof navigator !== "undefined" && navigator.onLine === false) return;
3009
+ if (pendingSeq === fatalSeq) return;
3010
+ const snapshot = pending;
3011
+ const sentSeq = pendingSeq;
3012
+ const body = buildBody(snapshot, sentSeq);
3013
+ inFlight = true;
3014
+ void fetch(opts.url, {
3015
+ method: "POST",
3016
+ headers: { "content-type": "application/json" },
3017
+ body,
3018
+ keepalive: true
3019
+ }).then((res) => {
3020
+ inFlight = false;
3021
+ if (disposed) return;
3022
+ if (res.ok) {
3023
+ attempt = 0;
3024
+ if (pendingSeq === sentSeq) pending = null;
3025
+ else trySend();
3026
+ return;
3027
+ }
3028
+ if (res.status >= 400 && res.status < 500) {
3029
+ if (process.env.NODE_ENV !== "production") {
3030
+ console.warn(
3031
+ `[appfunnel] snapshot persist rejected ${res.status} (dropping snapshot; check session/payload)`
3032
+ );
3033
+ }
3034
+ fatalSeq = sentSeq;
3035
+ if (pendingSeq === sentSeq) pending = null;
3036
+ else trySend();
3037
+ return;
3038
+ }
3039
+ scheduleRetry();
3040
+ }).catch(() => {
3041
+ inFlight = false;
3042
+ if (disposed) return;
3043
+ scheduleRetry();
3044
+ });
3045
+ };
3046
+ const scheduleRetry = () => {
3047
+ if (disposed || pending === null) return;
3048
+ const exp = Math.min(maxDelay, baseDelay * 2 ** attempt);
3049
+ const delay2 = Math.round(exp * (0.5 + rand() * 0.5));
3050
+ attempt++;
3051
+ clearRetry();
3052
+ retryTimer = setT(() => {
3053
+ retryTimer = null;
3054
+ trySend();
3055
+ }, delay2);
3056
+ };
3057
+ const push = (snapshot) => {
3058
+ if (disposed) return;
3059
+ pending = snapshot;
3060
+ pendingSeq = ++seq;
3061
+ if (fatalSeq !== -1 && pendingSeq !== fatalSeq) fatalSeq = -1;
3062
+ if (retryTimer === null) trySend();
3063
+ };
3064
+ const flushBeacon = () => {
3065
+ if (disposed || pending === null) return;
3066
+ const sessionId = opts.getSessionId();
3067
+ if (!sessionId) return;
3068
+ const body = buildBody(pending, pendingSeq);
3069
+ let delivered = false;
3070
+ if (typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function") {
3071
+ try {
3072
+ delivered = navigator.sendBeacon(
3073
+ opts.url,
3074
+ new Blob([body], { type: "application/json" })
3075
+ );
3076
+ } catch {
3077
+ delivered = false;
3078
+ }
3079
+ }
3080
+ if (!delivered) {
3081
+ try {
3082
+ void fetch(opts.url, {
3083
+ method: "POST",
3084
+ headers: { "content-type": "application/json" },
3085
+ body,
3086
+ keepalive: true
3087
+ }).catch(() => {
3088
+ });
3089
+ delivered = true;
3090
+ } catch {
3091
+ delivered = false;
3092
+ }
3093
+ }
3094
+ if (delivered) pending = null;
3095
+ };
3096
+ const onHidden = () => {
3097
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") {
3098
+ flushBeacon();
3099
+ }
3100
+ };
3101
+ const onPageHide = () => flushBeacon();
3102
+ const onOnline = () => trySend();
3103
+ if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
3104
+ try {
3105
+ document.addEventListener("visibilitychange", onHidden);
3106
+ window.addEventListener("pagehide", onPageHide);
3107
+ window.addEventListener("online", onOnline);
3108
+ } catch {
3109
+ }
3110
+ }
3111
+ const dispose = () => {
3112
+ disposed = true;
3113
+ clearRetry();
3114
+ pending = null;
3115
+ if (typeof window !== "undefined" && typeof window.removeEventListener === "function") {
3116
+ try {
3117
+ document.removeEventListener("visibilitychange", onHidden);
3118
+ window.removeEventListener("pagehide", onPageHide);
3119
+ window.removeEventListener("online", onOnline);
3120
+ } catch {
3121
+ }
3122
+ }
3123
+ };
3124
+ return { push, flushBeacon, dispose };
3125
+ }
3126
+
2716
3127
  // src/tracking/platformTracker.ts
2717
3128
  function validClickIds(acq) {
2718
3129
  return Object.entries(acq.clickIds ?? {}).filter(
@@ -2818,32 +3229,35 @@ function createPlatformTracker(cfg) {
2818
3229
  }
2819
3230
  };
2820
3231
  const VARS_DEBOUNCE_MS = 800;
3232
+ const dataUrl = `${base}/campaign/${encodeURIComponent(cfg.campaignId)}/event/session/data`;
2821
3233
  let pendingVars = null;
2822
3234
  let varsTimer = null;
3235
+ const persistence = createSnapshotPersistence({
3236
+ url: dataUrl,
3237
+ getSessionId: () => sessionId
3238
+ });
2823
3239
  const flushVars = () => {
2824
3240
  if (varsTimer) {
2825
3241
  clearTimeout(varsTimer);
2826
3242
  varsTimer = null;
2827
3243
  }
2828
3244
  if (!pendingVars) return;
2829
- const doPost = () => {
2830
- if (!pendingVars || !sessionId) return;
2831
- const userData = pendingVars;
2832
- pendingVars = null;
2833
- void fetch(`${base}/campaign/${encodeURIComponent(cfg.campaignId)}/event/session/data`, {
2834
- method: "POST",
2835
- headers: { "content-type": "application/json" },
2836
- body: JSON.stringify({ sessionId, userData }),
2837
- keepalive: true
2838
- }).catch((e) => {
2839
- if (process.env.NODE_ENV !== "production") console.warn("[appfunnel] variable persist failed:", e);
2840
- });
2841
- };
2842
- if (sessionId) doPost();
2843
- else chain = chain.then(doPost);
3245
+ const snapshot = pendingVars;
3246
+ pendingVars = null;
3247
+ persistence.push(snapshot);
3248
+ if (!sessionId) chain = chain.then(() => {
3249
+ persistence.push(snapshot);
3250
+ });
3251
+ };
3252
+ const drainOnExit = () => {
3253
+ if (pendingVars) {
3254
+ flushVars();
3255
+ persistence.flushBeacon();
3256
+ }
2844
3257
  };
2845
3258
  try {
2846
- window.addEventListener("pagehide", flushVars);
3259
+ window.addEventListener("visibilitychange", drainOnExit);
3260
+ window.addEventListener("pagehide", drainOnExit);
2847
3261
  } catch {
2848
3262
  }
2849
3263
  if (cfg.experiment?.id && cfg.experiment.variant) {
@@ -2886,6 +3300,7 @@ function createFunnelTree({
2886
3300
  }) {
2887
3301
  return function tree(opts) {
2888
3302
  const mode = opts.mode ?? "live";
3303
+ const embedEnabled = opts.embed ?? false;
2889
3304
  const tracker = opts.tracking ? createPlatformTracker({
2890
3305
  ...opts.tracking,
2891
3306
  visitorId: opts.visitorId,
@@ -2907,6 +3322,7 @@ function createFunnelTree({
2907
3322
  Fragment,
2908
3323
  null,
2909
3324
  createElement(CheckoutResume, null),
3325
+ embedEnabled ? createElement(EmbedBridge, null) : null,
2910
3326
  layout ? createElement(layout, null, children) : children
2911
3327
  );
2912
3328
  };
@@ -2920,6 +3336,7 @@ function createFunnelTree({
2920
3336
  tracker,
2921
3337
  checkout,
2922
3338
  sessionValues: opts.sessionValues,
3339
+ deferSessionValues: opts.deferSessionValues,
2923
3340
  experiments: opts.experiments,
2924
3341
  context: {
2925
3342
  mode,
@@ -2931,6 +3348,13 @@ function createFunnelTree({
2931
3348
  pages,
2932
3349
  layout: layoutWithResume,
2933
3350
  initialKey: opts.initialKey,
3351
+ // FLASH-FREE HYDRATION (Option B, FIX 1): trust the SERVER-resolved initialKey instead of
3352
+ // re-running the entry guard. The CLIENT sets this via deferSessionValues (its deferred
3353
+ // snapshot is empty, so re-running the guard would bounce a qualified deep-link and
3354
+ // mismatch the markup). The build-time PRERENDER sets `trustInitialKey` explicitly so a
3355
+ // guarded page prerenders its OWN default-state markup (the renderer picks target-vs-bounce
3356
+ // at serve time). Live SSR fallback leaves both off → the guard runs against real values.
3357
+ trustInitialKey: opts.trustInitialKey ?? opts.deferSessionValues,
2934
3358
  prefetch: "off"
2935
3359
  // renderer background-modulepreloads all chunks
2936
3360
  })