@growth-operations/ui-shared 1.34.0 → 1.36.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@growth-operations/ui-shared",
3
- "version": "1.34.0",
3
+ "version": "1.36.0",
4
4
  "description": "Shared HubSpot UI-extension modules (Home, Billing) for Growth Operations apps.",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -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";
@@ -12,7 +12,6 @@
12
12
  // supportPath — the app's support route path, e.g.
13
13
  // "/v1/hubspot/app_pages/<app_key>/support" (base-hosted) or
14
14
  // "/api/v1/support" (self-hosted).
15
- // appName — display name, used in headings/success copy.
16
15
  // supportUrl — external support link shown as a fallback on error.
17
16
  import React, { useState } from "react";
18
17
  import {
@@ -37,7 +36,7 @@ import {
37
36
  validateSupportFields,
38
37
  } from "./supportLogic";
39
38
 
40
- export function SupportTab({ context, supportPath, appName = "the app", supportUrl }) {
39
+ export function SupportTab({ context, supportPath, supportUrl }) {
41
40
  const [fields, setFields] = useState({
42
41
  firstname: context.user?.firstName ?? "",
43
42
  lastname: context.user?.lastName ?? "",
@@ -115,17 +114,14 @@ export function SupportTab({ context, supportPath, appName = "the app", supportU
115
114
  return (
116
115
  <Flex direction="column" gap="medium">
117
116
  <Flex direction="column" gap="small">
118
- <Heading>Contact {appName} support</Heading>
117
+ <Heading>Contact Us</Heading>
119
118
  <Text>
120
- Send us a message and we'll get back to you. For urgent issues you
121
- can also{" "}
122
- {supportUrl ? (
123
- <Link href={{ url: supportUrl, external: true }}>
124
- use our contact page
125
- </Link>
126
- ) : (
127
- "use our contact page"
128
- )}
119
+ Or shoot us your question at{" "}
120
+ <Link
121
+ href={{ url: "mailto:info@growthoperationsco.com", external: true }}
122
+ >
123
+ info@growthoperationsco.com
124
+ </Link>
129
125
  .
130
126
  </Text>
131
127
  </Flex>