@funnelsgrove/runtime 0.7.8 → 0.7.10

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.
@@ -13,6 +13,7 @@ type FunnelRuntimeConfigBoundaryProps = {
13
13
  errorFallback?: ReactNode;
14
14
  disabled?: boolean;
15
15
  };
16
+ export declare const FUNNEL_RUNTIME_CONFIG_REFRESH_INTERVAL_MS = 5000;
16
17
  export declare function FunnelRuntimeConfigBoundary({ disabled, ...props }: FunnelRuntimeConfigBoundaryProps): import("react/jsx-runtime").JSX.Element;
17
18
  export declare const useFunnelRuntimeConfig: () => FunnelRuntimeConfigState;
18
19
  export {};
@@ -18,14 +18,12 @@ const FunnelRuntimeConfigContext = createContext({
18
18
  config: null,
19
19
  error: null,
20
20
  });
21
+ export const FUNNEL_RUNTIME_CONFIG_REFRESH_INTERVAL_MS = 5000;
21
22
  const toError = (value) => (value instanceof Error ? value : new Error('Unable to load funnel runtime config'));
22
23
  /**
23
- * Loads the active publication once before the funnel mounts. The resolved
24
- * snapshot is deeply frozen and stays fixed for the lifetime of this boundary,
25
- * so one visitor can never switch experiments or prices mid-session.
26
- *
27
- * A published funnel never falls back to generated pricing or experiment
28
- * config: the version-bound artifact is its single source of truth.
24
+ * Loads the latest publication before mount and then follows config-only
25
+ * publications for the same source funnel version. Each resolved snapshot is
26
+ * deeply frozen; publication revisions are the only supported update boundary.
29
27
  */
30
28
  function ActiveFunnelRuntimeConfigBoundary({ funnelId, funnelVersionId, children, loadingFallback = null, errorFallback = null, }) {
31
29
  const [state, setState] = useState({
@@ -35,22 +33,37 @@ function ActiveFunnelRuntimeConfigBoundary({ funnelId, funnelVersionId, children
35
33
  });
36
34
  useEffect(() => {
37
35
  let active = true;
38
- loadFunnelRuntimeConfig(funnelId, { versionId: funnelVersionId })
36
+ let refreshTimer = null;
37
+ const refresh = () => loadFunnelRuntimeConfig(funnelId, { versionId: funnelVersionId })
39
38
  .then((published) => resolvePublishedFunnelRuntimeConfig(published, {
40
39
  expectedFunnelVersionId: funnelVersionId,
41
40
  }))
42
41
  .then((config) => {
43
- if (active) {
44
- setState({ status: 'published', config, error: null });
45
- }
42
+ if (!active)
43
+ return;
44
+ setState((current) => {
45
+ var _a;
46
+ return (current.status === 'published' && ((_a = current.config) === null || _a === void 0 ? void 0 : _a.revisionId) === config.revisionId
47
+ ? current
48
+ : { status: 'published', config, error: null });
49
+ });
46
50
  })
47
51
  .catch((error) => {
52
+ if (!active)
53
+ return;
54
+ setState((current) => (current.status === 'published'
55
+ ? current
56
+ : { status: 'fallback', config: null, error: toError(error) }));
57
+ });
58
+ void refresh().then(() => {
48
59
  if (active) {
49
- setState({ status: 'fallback', config: null, error: toError(error) });
60
+ refreshTimer = setInterval(() => void refresh(), FUNNEL_RUNTIME_CONFIG_REFRESH_INTERVAL_MS);
50
61
  }
51
62
  });
52
63
  return () => {
53
64
  active = false;
65
+ if (refreshTimer)
66
+ clearInterval(refreshTimer);
54
67
  };
55
68
  }, [funnelId, funnelVersionId]);
56
69
  const contextValue = useMemo(() => state, [state]);
package/dist/index.d.ts CHANGED
@@ -28,6 +28,7 @@ export * from './runtime/published-runtime-resolution.js';
28
28
  export * from './runtime/route-resolver.js';
29
29
  export * from './runtime/submit-email-capture.js';
30
30
  export * from './runtime/subscription-handoff.js';
31
+ export * from './runtime/step-asset-preloader.js';
31
32
  export * from './runtime/url-user-attributes.js';
32
33
  export * from './runtime/use-url-user-attributes-sync.js';
33
34
  export * from './services/api.service.js';
package/dist/index.js CHANGED
@@ -29,6 +29,7 @@ export * from './runtime/published-runtime-resolution.js';
29
29
  export * from './runtime/route-resolver.js';
30
30
  export * from './runtime/submit-email-capture.js';
31
31
  export * from './runtime/subscription-handoff.js';
32
+ export * from './runtime/step-asset-preloader.js';
32
33
  export * from './runtime/url-user-attributes.js';
33
34
  export * from './runtime/use-url-user-attributes-sync.js';
34
35
  // Browser-safe services.
@@ -0,0 +1,8 @@
1
+ import type { FunnelManifest } from '../config/funnel.manifest.types.js';
2
+ export type StepImagePreload = {
3
+ src: string;
4
+ width?: number;
5
+ height?: number;
6
+ };
7
+ export declare const resolveNextStepImagePreloads: (manifest: FunnelManifest, currentStepId: string) => StepImagePreload[];
8
+ export declare const scheduleNextStepImagePreload: (manifest: FunnelManifest, currentStepId: string) => (() => void);
@@ -0,0 +1,44 @@
1
+ const normalizeIdleImageAsset = (asset) => {
2
+ if (!asset || typeof asset === 'string' || asset.preload !== 'idle') {
3
+ return null;
4
+ }
5
+ return {
6
+ src: asset.src,
7
+ width: asset.width,
8
+ height: asset.height,
9
+ };
10
+ };
11
+ export const resolveNextStepImagePreloads = (manifest, currentStepId) => {
12
+ var _a, _b;
13
+ const nextStepIds = ((_a = manifest.edgesByStepId[currentStepId]) !== null && _a !== void 0 ? _a : [])
14
+ .map((edge) => edge.toStepId);
15
+ const preloadBySrc = new Map();
16
+ for (const nextStepId of nextStepIds) {
17
+ const nextStep = manifest.steps.find((step) => step.id === nextStepId);
18
+ for (const assetId of (_b = nextStep === null || nextStep === void 0 ? void 0 : nextStep.assetIds) !== null && _b !== void 0 ? _b : []) {
19
+ const preload = normalizeIdleImageAsset(manifest.assets[assetId]);
20
+ if (preload && !preloadBySrc.has(preload.src)) {
21
+ preloadBySrc.set(preload.src, preload);
22
+ }
23
+ }
24
+ }
25
+ return [...preloadBySrc.values()];
26
+ };
27
+ export const scheduleNextStepImagePreload = (manifest, currentStepId) => {
28
+ const preloads = resolveNextStepImagePreloads(manifest, currentStepId);
29
+ if (typeof window === 'undefined' || preloads.length === 0) {
30
+ return () => undefined;
31
+ }
32
+ const preload = () => {
33
+ for (const asset of preloads) {
34
+ const image = new window.Image();
35
+ image.src = asset.src;
36
+ }
37
+ };
38
+ if (typeof window.requestIdleCallback === 'function') {
39
+ const idleCallbackId = window.requestIdleCallback(preload);
40
+ return () => window.cancelIdleCallback(idleCallbackId);
41
+ }
42
+ const timeoutId = window.setTimeout(preload, 200);
43
+ return () => window.clearTimeout(timeoutId);
44
+ };
@@ -259,7 +259,8 @@ export const loadFunnelRuntimeConfig = (funnelId, options = {}) => {
259
259
  const fetcher = options.fetcher || globalThis.fetch;
260
260
  const basePath = (options.basePath || '/api/funnel-config').replace(/\/$/, '');
261
261
  const request = fetcher(`${basePath}/${encodeURIComponent(normalizedFunnelId)}?versionId=${encodeURIComponent(versionId)}`, {
262
- headers: { accept: 'application/json' },
262
+ cache: 'no-store',
263
+ headers: { accept: 'application/json', 'cache-control': 'no-cache' },
263
264
  }).then(async (response) => {
264
265
  if (!response.ok) {
265
266
  throw new Error(`Unable to load funnel runtime config (${response.status})`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@funnelsgrove/runtime",
3
- "version": "0.7.8",
3
+ "version": "0.7.10",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/The-Solid-Grove/funnelsgrove.git",