@growth-operations/ui-shared 1.34.0 → 1.35.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.
- package/package.json +1 -1
- package/src/billing/SubscriptionGate.jsx +45 -1
- package/src/context/readiness.js +33 -0
- package/src/index.js +7 -0
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useState } from "react";
|
|
1
|
+
import React, { useEffect, useReducer, useState } from "react";
|
|
2
2
|
import {
|
|
3
3
|
Alert,
|
|
4
4
|
Box,
|
|
@@ -6,10 +6,12 @@ import {
|
|
|
6
6
|
Link,
|
|
7
7
|
LoadingSpinner,
|
|
8
8
|
Text,
|
|
9
|
+
logger,
|
|
9
10
|
} from "@hubspot/ui-extensions";
|
|
10
11
|
|
|
11
12
|
import { useStrictModeEffect } from "../lib/useStrictModeEffect";
|
|
12
13
|
import { callAppApi } from "../sdk/app/base";
|
|
14
|
+
import { contextReadiness } from "../context/readiness";
|
|
13
15
|
|
|
14
16
|
// Full-surface "no active subscription" gate for CRM cards (and any other
|
|
15
17
|
// extension surface that should hard-block a canceled install).
|
|
@@ -38,15 +40,31 @@ import { callAppApi } from "../sdk/app/base";
|
|
|
38
40
|
// the card's existing error handling, since the gated endpoints 402 with the
|
|
39
41
|
// same { detail, code: "subscription_canceled", billing_url } body.
|
|
40
42
|
//
|
|
43
|
+
// Children also wait for the extension IDENTIFIER context to hydrate
|
|
44
|
+
// (2026.03 runtime race — see context/readiness.js): at first render
|
|
45
|
+
// context.extension.appId is the placeholder string "Identifier context is
|
|
46
|
+
// not found", so CRM hooks subscribing immediately send garbage property
|
|
47
|
+
// names and can wedge in isLoading forever when the corrected re-subscription's
|
|
48
|
+
// delivery is dropped (the "spinner until the user switches browser tabs"
|
|
49
|
+
// freeze). The entitlement check above doesn't need the identifier context
|
|
50
|
+
// (portal.id only), so it starts immediately and runs in parallel; only the
|
|
51
|
+
// children render is held, polled at 4Hz (the hydration re-render trigger is
|
|
52
|
+
// platform-internal), with a bounded 8s fallback that renders children anyway
|
|
53
|
+
// rather than spinning forever if hydration never comes.
|
|
54
|
+
//
|
|
41
55
|
// Props:
|
|
42
56
|
// context — the hubspot.extend context (portal.id, app.id, variables).
|
|
43
57
|
// checkPath — backend entitlement-check path; default "/v1/entitlement".
|
|
44
58
|
// appName — display name for the copy, e.g. "Applicant Tracking System".
|
|
59
|
+
// requiredVariables — context.variables keys the child tree's hooks depend
|
|
60
|
+
// on (e.g. ATS's ["APPLICATION_OBJECT_TYPE_ID"]); the context
|
|
61
|
+
// gate waits for these too.
|
|
45
62
|
export function SubscriptionGate({
|
|
46
63
|
context,
|
|
47
64
|
children,
|
|
48
65
|
checkPath = "/v1/entitlement",
|
|
49
66
|
appName = "this app",
|
|
67
|
+
requiredVariables = [],
|
|
50
68
|
}) {
|
|
51
69
|
// null = still loading; { entitled, status, billing_url } once resolved.
|
|
52
70
|
const [check, setCheck] = useState(null);
|
|
@@ -67,6 +85,32 @@ export function SubscriptionGate({
|
|
|
67
85
|
[context, checkPath]
|
|
68
86
|
);
|
|
69
87
|
|
|
88
|
+
const { ready: contextReady, missing: contextMissing } = contextReadiness(
|
|
89
|
+
context,
|
|
90
|
+
requiredVariables
|
|
91
|
+
);
|
|
92
|
+
const contextMissingKey = contextMissing.join(", ");
|
|
93
|
+
const [contextTimedOut, setContextTimedOut] = useState(false);
|
|
94
|
+
const [, forcePoll] = useReducer((x) => x + 1, 0);
|
|
95
|
+
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (contextReady || contextTimedOut) return;
|
|
98
|
+
const poll = setInterval(forcePoll, 250);
|
|
99
|
+
const timeout = setTimeout(() => {
|
|
100
|
+
logger.error(
|
|
101
|
+
`[SubscriptionGate] Extension context never hydrated (missing: ${contextMissingKey}) — rendering children anyway`
|
|
102
|
+
);
|
|
103
|
+
setContextTimedOut(true);
|
|
104
|
+
}, 8000);
|
|
105
|
+
return () => {
|
|
106
|
+
clearInterval(poll);
|
|
107
|
+
clearTimeout(timeout);
|
|
108
|
+
};
|
|
109
|
+
}, [contextReady, contextTimedOut, contextMissingKey]);
|
|
110
|
+
|
|
111
|
+
if (!contextReady && !contextTimedOut) {
|
|
112
|
+
return <LoadingSpinner label="Loading…" showLabel />;
|
|
113
|
+
}
|
|
70
114
|
if (failedOpen) return children;
|
|
71
115
|
if (!check) {
|
|
72
116
|
return <LoadingSpinner label="Checking subscription…" showLabel />;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Extension identifier-context readiness (pure helpers).
|
|
2
|
+
//
|
|
3
|
+
// Platform race (HubSpot 2026.03 runtime, verified live 2026-10-22 on the ATS
|
|
4
|
+
// demo portal): context.extension.appId is NOT usable at first render — it
|
|
5
|
+
// evaluates to the placeholder string "Identifier context is not found", so
|
|
6
|
+
// every `a${context.extension.appId}_*` property name composed during that
|
|
7
|
+
// window is garbage until the identifier context hydrates a beat later. CRM
|
|
8
|
+
// hooks (useCrmProperties / useAssociations) that subscribe in that window
|
|
9
|
+
// send a first resolveData wave with garbage names, then re-subscribe with
|
|
10
|
+
// correct ones; when the runtime drops that second delivery into the iframe,
|
|
11
|
+
// the hook wedges in isLoading forever — the card "loads instantly" only
|
|
12
|
+
// after the user switches browser tabs (HubSpot re-resolves the record on
|
|
13
|
+
// window focus). SubscriptionGate uses these to hold children until hooks
|
|
14
|
+
// can subscribe with correct names on the FIRST try.
|
|
15
|
+
|
|
16
|
+
export const isUsableAppId = (appId) =>
|
|
17
|
+
appId != null && /^\d+$/.test(String(appId));
|
|
18
|
+
|
|
19
|
+
// What (if anything) is still missing before hooks may subscribe.
|
|
20
|
+
// requiredVariables names context.variables keys the tree's hooks depend on
|
|
21
|
+
// (e.g. ATS's APPLICATION_OBJECT_TYPE_ID).
|
|
22
|
+
export const contextReadiness = (context, requiredVariables = []) => {
|
|
23
|
+
const missing = [];
|
|
24
|
+
if (!isUsableAppId(context?.extension?.appId)) {
|
|
25
|
+
missing.push("extension.appId");
|
|
26
|
+
}
|
|
27
|
+
for (const name of requiredVariables) {
|
|
28
|
+
if (!context?.variables?.[name]) {
|
|
29
|
+
missing.push(`variables.${name}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return { ready: missing.length === 0, missing };
|
|
33
|
+
};
|
package/src/index.js
CHANGED
|
@@ -43,6 +43,13 @@ export {
|
|
|
43
43
|
// Sandbox — the shared "Install on a sandbox" action (mints a signed link).
|
|
44
44
|
export { SandboxInstall } from "./sandbox/SandboxInstall";
|
|
45
45
|
|
|
46
|
+
// Platform — extension identifier-context readiness (2026.03 runtime:
|
|
47
|
+
// context.extension.appId is a placeholder string at first render; hooks that
|
|
48
|
+
// subscribe in that window can wedge isLoading forever). SubscriptionGate
|
|
49
|
+
// holds children on this internally; the pure helpers are exported for
|
|
50
|
+
// surfaces that gate without SubscriptionGate. See src/context/readiness.js.
|
|
51
|
+
export { contextReadiness, isUsableAppId } from "./context/readiness";
|
|
52
|
+
|
|
46
53
|
// Support — the shared in-app support request form (posts to the app's
|
|
47
54
|
// support route; see common.support).
|
|
48
55
|
export { SupportTab } from "./support/SupportTab";
|