Repository: descope/descope-js
Pull request #1053: feat(client-scripts): add token refresh functionality
Author: login: orius123
Created: 2025-03-17T20:29:46Z | Merged: 2025-03-18T20:30:48Z

## Related Issues

Fixes https://github.com/descope/etc/issues/9559
## Description


- Adding a token refresh mechanism to ensure valid tokens during form submission
- Implementing proper timeout handling to prevent UI blocking



Diff:
diff --git a/packages/sdks/web-component/src/lib/descope-wc/DescopeWc.ts b/packages/sdks/web-component/src/lib/descope-wc/DescopeWc.ts
index 8a009ccca..2a630be19 100644
--- a/packages/sdks/web-component/src/lib/descope-wc/DescopeWc.ts
+++ b/packages/sdks/web-component/src/lib/descope-wc/DescopeWc.ts
@@ -56,6 +56,7 @@ import {
   NextFn,
   NextFnReturnPromiseValue,
   ScriptElement,
+  ScriptModule,
   SdkConfig,
   StepState,
 } from '../types';
@@ -171,6 +172,22 @@ class DescopeWc extends BaseDescopeWc {
       }
     | undefined;
 
+  /**
+   * Get all loaded SDK script modules from elements with data-script-id attribute
+   * @returns Array of script modules that can be refreshed before form submission
+   */
+  loadSdkScriptsModules() {
+    // Get all modules from the data-script-id elements
+    const scriptElements = this.shadowRoot.querySelectorAll(
+      'div[data-script-id]',
+    );
+
+    // Filter out elements without moduleRes property
+    return Array.from(scriptElements)
+      .map((el) => (el as ScriptElement).moduleRes)
+      .filter((module): module is ScriptModule => !!module);
+  }
+
   loadSdkScripts(scripts: ClientScript[]) {
     if (!scripts?.length) {
       return null;
@@ -1444,6 +1461,26 @@ class DescopeWc extends BaseDescopeWc {
             (Date.now() - now).toString(),
           );
         }
+
+        // Get all script modules and refresh them before form submission
+        const sdkScriptsModules = this.loadSdkScriptsModules();
+
+        if (sdkScriptsModules.length > 0) {
+          // Only attempt to refresh modules that actually have a refresh function
+          const refreshPromises = sdkScriptsModules
+            .filter((module) => typeof module.refresh === 'function')
+            .map((module) => module.refresh!());
+
+          if (refreshPromises.length > 0) {
+            // Use timeout to prevent hanging if refresh takes too long
+            await timeoutPromise(
+              SDK_SCRIPTS_LOAD_TIMEOUT,
+              Promise.all(refreshPromises),
+              null,
+            );
+          }
+        }
+
         const contextArgs = this.getComponentsContext();
 
         const actionArgs = {
diff --git a/packages/sdks/web-component/src/lib/descope-wc/sdkScripts/grecaptcha.ts b/packages/sdks/web-component/src/lib/descope-wc/sdkScripts/grecaptcha.ts
index f892c379b..2b9e750a7 100644
--- a/packages/sdks/web-component/src/lib/descope-wc/sdkScripts/grecaptcha.ts
+++ b/packages/sdks/web-component/src/lib/descope-wc/sdkScripts/grecaptcha.ts
@@ -10,6 +10,11 @@ declare global {
   }
 }
 
+// Token refresh time: 105 seconds (2 minutes minus 15 seconds)
+// Set to refresh the token shortly before expiration to ensure
+// we always have a valid token when submitting the form
+const TOKEN_REFRESH_TIME_MS = 105000;
+
 export const loadGRecaptcha = (
   initArgs: {
     enterprise: boolean;
@@ -18,6 +23,8 @@ export const loadGRecaptcha = (
   _inputs: { baseUrl?: string },
   onTokenReady: (token: string) => void,
 ) => {
+  let lastTokenFetchTime = new Date().getTime();
+
   const getScriptURL = () => {
     const url = new URL('https://www.google.com/recaptcha/');
     url.pathname += `${initArgs.enterprise ? 'enterprise' : 'api'}.js`;
@@ -54,10 +61,11 @@ export const loadGRecaptcha = (
             console.warn('could not execute recaptcha', e);
           } else {
             onTokenReady(token);
+            lastTokenFetchTime = new Date().getTime();
             // if the component is still connected, we should try to get a new token before the token expires (2 minutes)
             timer = setTimeout(() => {
               getNewToken(grecaptchaInstance, currentNode);
-            }, 110000);
+            }, TOKEN_REFRESH_TIME_MS);
           }
         });
     });
@@ -84,6 +92,46 @@ export const loadGRecaptcha = (
     getNewToken(grecaptchaInstance, elementRef);
   };
 
+  /**
+   * Checks if the reCAPTCHA token needs refreshing and refreshes it if necessary
+   * This is called before form submission to ensure we have a valid token
+   * @returns Promise that resolves when token is refreshed or if refresh isn't needed
+   */
+  const refreshIfTokenExpired = async (): Promise<void> => {
+    const currentTime = Date.now();
+    const timeDiff = currentTime - lastTokenFetchTime;
+
+    if (timeDiff > TOKEN_REFRESH_TIME_MS) {
+      stopTimer();
+      const prev = lastTokenFetchTime;
+      resumeScriptExecution();
+
+      // Return a promise that resolves once the token is refreshed or times out
+      return new Promise<void>((resolve) => {
+        // Set a timeout to prevent indefinite waiting
+        const timeout = setTimeout(() => {
+          // eslint-disable-next-line no-console
+          console.warn('reCAPTCHA token refresh timed out');
+          resolve(); // Resolve anyway to prevent blocking form submission
+        }, 5000); // 5 second timeout for token refresh
+
+        const checkToken = () => {
+          if (lastTokenFetchTime !== prev) {
+            clearTimeout(timeout);
+            resolve();
+          } else {
+            setTimeout(checkToken, 150);
+          }
+        };
+
+        checkToken();
+      });
+    }
+
+    // If no refresh is needed, return a resolved promise
+    return Promise.resolve();
+  };
+
   const createOnLoadScript = () => {
     window.onRecaptchaLoadCallback = () => {
       const currentNode = elementRef;
@@ -113,7 +161,11 @@ export const loadGRecaptcha = (
   createOnLoadScript();
   loadRecaptchaScript();
 
-  return { stop: stopTimer, start: resumeScriptExecution };
+  return {
+    stop: stopTimer,
+    start: resumeScriptExecution,
+    refresh: refreshIfTokenExpired,
+  };
 };
 
 export default loadGRecaptcha;
diff --git a/packages/sdks/web-component/src/lib/types.ts b/packages/sdks/web-component/src/lib/types.ts
index cfb71492f..53f83b002 100644
--- a/packages/sdks/web-component/src/lib/types.ts
+++ b/packages/sdks/web-component/src/lib/types.ts
@@ -153,6 +153,11 @@ export interface ScriptElement extends HTMLDivElement {
 export type ScriptModule = {
   stop: () => void;
   start: () => void;
+  /**
+   * Refreshes any tokens or state that might be needed before form submission
+   * Currently implemented for reCAPTCHA to ensure we have a fresh token
+   */
+  refresh?: () => Promise<void>;
 };
 
 export type ClientScript = {
diff --git a/packages/sdks/web-component/test/sdkScripts/grecaptcha.test.ts b/packages/sdks/web-component/test/sdkScripts/grecaptcha.test.ts
new file mode 100644
index 000000000..571e86b8c
--- /dev/null
+++ b/packages/sdks/web-component/test/sdkScripts/grecaptcha.test.ts
@@ -0,0 +1,74 @@
+import { loadGRecaptcha } from '../../src/lib/descope-wc/sdkScripts/grecaptcha';
+
+describe('reCAPTCHA script', () => {
+  let onTokenReady;
+  let element;
+
+  beforeEach(() => {
+    // Mock DOM elements
+    element = document.createElement('div');
+    document.body.appendChild(element);
+
+    // Mock the token ready callback
+    onTokenReady = jest.fn();
+
+    // Mock the grecaptcha object
+    global.grecaptcha = {
+      enterprise: {
+        execute: jest.fn(() => Promise.resolve('mock-token')),
+        ready: jest.fn((callback) => callback()),
+        render: jest.fn(() => 'render-id'),
+      },
+      ready: jest.fn((callback) => callback()),
+      execute: jest.fn(() => Promise.resolve('mock-token')),
+      render: jest.fn(() => 'render-id'),
+    };
+
+    // Mock the window.onRecaptchaLoadCallback
+    global.onRecaptchaLoadCallback = null;
+
+    // Mock Date.now
+    jest.spyOn(Date, 'now').mockImplementation(() => 1000);
+  });
+
+  afterEach(() => {
+    jest.clearAllMocks();
+    document.body.removeChild(element);
+  });
+
+  it('should load reCAPTCHA script and return proper module', () => {
+    const initArgs = {
+      enterprise: false,
+      siteKey: 'test-site-key',
+    };
+
+    const module = loadGRecaptcha(initArgs, {}, onTokenReady);
+
+    // Check that the module has the expected methods
+    expect(module).toHaveProperty('stop');
+    expect(module).toHaveProperty('start');
+    expect(module).toHaveProperty('refresh');
+
+    // Verify the methods are functions
+    expect(typeof module.stop).toBe('function');
+    expect(typeof module.start).toBe('function');
+    expect(typeof module.refresh).toBe('function');
+  });
+
+  it('should have a refresh method with timeout safety', () => {
+    const initArgs = {
+      enterprise: false,
+      siteKey: 'test-site-key',
+    };
+
+    // Create module
+    const module = loadGRecaptcha(initArgs, {}, onTokenReady);
+
+    // Verify refresh method exists and returns a promise
+    expect(typeof module.refresh).toBe('function');
+
+    // Verify the refresh method returns a Promise
+    const refreshPromise = module.refresh();
+    expect(refreshPromise).toBeInstanceOf(Promise);
+  });
+});


Reviews:
user: login: Bars92
body: lemme know what you think of the comments
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-03-18T17:18:07Z
user: login: orius123
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-18T17:19:12Z
user: login: Bars92
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-18T20:30:46Z

Review comments:
diff_hunk: @@ -10,6 +10,11 @@ declare global {
   }
 }
 
+// Token refresh time: 105 seconds (2 minutes minus 15 seconds)
+// Set to refresh the token shortly before expiration to ensure
+// we always have a valid token when submitting the form
+const TOKEN_REFRESH_TIME = 105000;
path: packages/sdks/web-component/src/lib/descope-wc/sdkScripts/grecaptcha.ts
user: login: Bars92
body: maybe rename 
```suggestion
const TOKEN_REFRESH_TIME_MS = 105000;
```
created_at: 2025-03-18T17:14:58Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,74 @@
+import { loadGRecaptcha } from '../../src/lib/descope-wc/sdkScripts/grecaptcha';
+
+describe('reCAPTCHA script', () => {
path: packages/sdks/web-component/test/sdkScripts/grecaptcha.test.ts
user: login: Bars92
body: mm this kinda conflicts ill need to move this to content which i wasn't planning but ill try
created_at: 2025-03-18T17:15:26Z
author_association: MEMBER
diff_hunk: @@ -84,6 +92,46 @@ export const loadGRecaptcha = (
     getNewToken(grecaptchaInstance, elementRef);
   };
 
+  /**
+   * Checks if the reCAPTCHA token needs refreshing and refreshes it if necessary
+   * This is called before form submission to ensure we have a valid token
+   * @returns Promise that resolves when token is refreshed or if refresh isn't needed
+   */
+  const refreshIfTokenExpired = async (): Promise<void> => {
+    const currentTime = Date.now();
+    const timeDiff = currentTime - lastTokenFetchTime;
+
+    if (timeDiff > TOKEN_REFRESH_TIME) {
+      stopTimer();
+      const prev = lastTokenFetchTime;
+      resumeScriptExecution();
+
+      // Return a promise that resolves once the token is refreshed or times out
+      return new Promise<void>((resolve) => {
+        // Set a timeout to prevent indefinite waiting
+        const timeout = setTimeout(() => {
+          // eslint-disable-next-line no-console
+          console.warn('reCAPTCHA token refresh timed out');
+          resolve(); // Resolve anyway to prevent blocking form submission
+        }, 5000); // 5 second timeout for token refresh
+
+        const checkToken = () => {
+          if (lastTokenFetchTime !== prev) {
+            clearTimeout(timeout);
+            resolve();
+          } else {
+            setTimeout(checkToken, 150);
+          }
path: packages/sdks/web-component/src/lib/descope-wc/sdkScripts/grecaptcha.ts
user: login: Bars92
body: 👍  but why 150? why not 1000 or more? tickets should be in seconds probably
created_at: 2025-03-18T17:17:40Z
author_association: MEMBER
diff_hunk: @@ -84,6 +92,46 @@ export const loadGRecaptcha = (
     getNewToken(grecaptchaInstance, elementRef);
   };
 
+  /**
+   * Checks if the reCAPTCHA token needs refreshing and refreshes it if necessary
+   * This is called before form submission to ensure we have a valid token
+   * @returns Promise that resolves when token is refreshed or if refresh isn't needed
+   */
+  const refreshIfTokenExpired = async (): Promise<void> => {
+    const currentTime = Date.now();
+    const timeDiff = currentTime - lastTokenFetchTime;
+
+    if (timeDiff > TOKEN_REFRESH_TIME) {
+      stopTimer();
+      const prev = lastTokenFetchTime;
+      resumeScriptExecution();
+
+      // Return a promise that resolves once the token is refreshed or times out
+      return new Promise<void>((resolve) => {
+        // Set a timeout to prevent indefinite waiting
+        const timeout = setTimeout(() => {
+          // eslint-disable-next-line no-console
+          console.warn('reCAPTCHA token refresh timed out');
+          resolve(); // Resolve anyway to prevent blocking form submission
+        }, 5000); // 5 second timeout for token refresh
+
+        const checkToken = () => {
+          if (lastTokenFetchTime !== prev) {
+            clearTimeout(timeout);
+            resolve();
+          } else {
+            setTimeout(checkToken, 150);
+          }
path: packages/sdks/web-component/src/lib/descope-wc/sdkScripts/grecaptcha.ts
user: login: orius123
body: If it will be 1000, so it will always take at least 1000 sec of waiting 
created_at: 2025-03-18T17:19:12Z
author_association: MEMBER

PR comments:
user: login: vercel[bot]
created_at: 2025-03-17T20:29:52Z
body: [vc]: #ZL0teaBjLNw7UDKB+ZpTKbgEkjhc5ezpRYo9q6l48Fg=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJ1c2VyLW1hbmFnZW1lbnQtd2lkZ2V0Iiwicm9vdERpcmVjdG9yeSI6bnVsbCwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL2Rlc2NvcGUvdXNlci1tYW5hZ2VtZW50LXdpZGdldC9HdkZHd3dTMzJrYnBjZ21yWlozQlkzYVpOY2c3IiwicHJldmlld1VybCI6InVzZXItbWFuYWdlbWVudC13aWRnZXQtZ2l0LXdhaXQtZm9yLXJlZnJlc2gucHJldmlldy5kZXNjb3BlLm9yZyIsIm5leHRDb21taXRTdGF0dXMiOiJERVBMT1lFRCIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiJ1c2VyLW1hbmFnZW1lbnQtd2lkZ2V0LWdpdC13YWl0LWZvci1yZWZyZXNoLnByZXZpZXcuZGVzY29wZS5vcmcifX0seyJuYW1lIjoicm9sZS1tYW5hZ2VtZW50LXdpZGdldCIsInJvb3REaXJlY3RvcnkiOm51bGwsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiJyb2xlLW1hbmFnZW1lbnQtd2lkZ2V0LWdpdC13YWl0LWZvci1yZWZyZXNoLnByZXZpZXcuZGVzY29wZS5vcmcifSwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL2Rlc2NvcGUvcm9sZS1tYW5hZ2VtZW50LXdpZGdldC9IWFJwTFJMWkV5clJNMXJDQjhUMlFtZ2UzcXg4IiwicHJldmlld1VybCI6InJvbGUtbWFuYWdlbWVudC13aWRnZXQtZ2l0LXdhaXQtZm9yLXJlZnJlc2gucHJldmlldy5kZXNjb3BlLm9yZyIsIm5leHRDb21taXRTdGF0dXMiOiJERVBMT1lFRCJ9LHsibmFtZSI6ImF1ZGl0LW1hbmFnZW1lbnQtd2lkZ2V0Iiwicm9vdERpcmVjdG9yeSI6bnVsbCwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6ImF1ZGl0LW1hbmFnZW1lbnQtd2lkZ2V0LWdpdC13YWl0LWZvci1yZWZyZXNoLnByZXZpZXcuZGVzY29wZS5vcmcifSwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL2Rlc2NvcGUvYXVkaXQtbWFuYWdlbWVudC13aWRnZXQvMlpzd2tWR2JqcHg2Y2ZIREp0NWFQcm9SWkFFcCIsInByZXZpZXdVcmwiOiJhdWRpdC1tYW5hZ2VtZW50LXdpZGdldC1naXQtd2FpdC1mb3ItcmVmcmVzaC5wcmV2aWV3LmRlc2NvcGUub3JnIiwibmV4dENvbW1pdFN0YXR1cyI6IkRFUExPWUVEIn0seyJuYW1lIjoidXNlci1wcm9maWxlLXdpZGdldCIsInJvb3REaXJlY3RvcnkiOm51bGwsImluc3BlY3RvclVybCI6Imh0dHBzOi8vdmVyY2VsLmNvbS9kZXNjb3BlL3VzZXItcHJvZmlsZS13aWRnZXQvODNmaEI5dGlLQ005TUhObkhTREpkVWpWY0czbSIsInByZXZpZXdVcmwiOiJ1c2VyLXByb2ZpbGUtd2lkZ2V0LWdpdC13YWl0LWZvci1yZWZyZXNoLnByZXZpZXcuZGVzY29wZS5vcmciLCJuZXh0Q29tbWl0U3RhdHVzIjoiREVQTE9ZRUQiLCJsaXZlRmVlZGJhY2siOnsicmVzb2x2ZWQiOjAsInVucmVzb2x2ZWQiOjAsInRvdGFsIjowLCJsaW5rIjoidXNlci1wcm9maWxlLXdpZGdldC1naXQtd2FpdC1mb3ItcmVmcmVzaC5wcmV2aWV3LmRlc2NvcGUub3JnIn19LHsibmFtZSI6ImFjY2Vzcy1rZXktbWFuYWdlbWVudC13aWRnZXQiLCJyb290RGlyZWN0b3J5IjpudWxsLCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vZGVzY29wZS9hY2Nlc3Mta2V5LW1hbmFnZW1lbnQtd2lkZ2V0L0dvQVM1RVZIYTNlakxYZWNGc1ZlZUxpVWk4VHgiLCJwcmV2aWV3VXJsIjoiYWNjZXNzLWtleS1tYW5hZ2VtZW50LXdpZGdldC1naXQtd2FpdC1mb3ItcmVmcmVzaC5wcmV2aWV3LmRlc2NvcGUub3JnIiwibmV4dENvbW1pdFN0YXR1cyI6IkRFUExPWUVEIiwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6ImFjY2Vzcy1rZXktbWFuYWdlbWVudC13aWRnZXQtZ2l0LXdhaXQtZm9yLXJlZnJlc2gucHJldmlldy5kZXNjb3BlLm9yZyJ9fV19
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **access-key-management-widget** | ✅ Ready ([Inspect](https://vercel.com/descope/access-key-management-widget/GoAS5EVHa3ejLXecFsVeeLiUi8Tx)) | [Visit Preview](https://access-key-management-widget-git-wait-for-refresh.preview.descope.org) | 💬 [**Add feedback**](https://vercel.live/open-feedback/access-key-management-widget-git-wait-for-refresh.preview.descope.org?via=pr-comment-feedback-link) | Mar 18, 2025 5:50pm |
| **audit-management-widget** | ✅ Ready ([Inspect](https://vercel.com/descope/audit-management-widget/2ZswkVGbjpx6cfHDJt5aProRZAEp)) | [Visit Preview](https://audit-management-widget-git-wait-for-refresh.preview.descope.org) | 💬 [**Add feedback**](https://vercel.live/open-feedback/audit-management-widget-git-wait-for-refresh.preview.descope.org?via=pr-comment-feedback-link) | Mar 18, 2025 5:50pm |
| **role-management-widget** | ✅ Ready ([Inspect](https://vercel.com/descope/role-management-widget/HXRpLRLZEyrRM1rCB8T2Qmge3qx8)) | [Visit Preview](https://role-management-widget-git-wait-for-refresh.preview.descope.org) | 💬 [**Add feedback**](https://vercel.live/open-feedback/role-management-widget-git-wait-for-refresh.preview.descope.org?via=pr-comment-feedback-link) | Mar 18, 2025 5:50pm |
| **user-management-widget** | ✅ Ready ([Inspect](https://vercel.com/descope/user-management-widget/GvFGwwS32kbpcgmrZZ3BY3aZNcg7)) | [Visit Preview](https://user-management-widget-git-wait-for-refresh.preview.descope.org) | 💬 [**Add feedback**](https://vercel.live/open-feedback/user-management-widget-git-wait-for-refresh.preview.descope.org?via=pr-comment-feedback-link) | Mar 18, 2025 5:50pm |
| **user-profile-widget** | ✅ Ready ([Inspect](https://vercel.com/descope/user-profile-widget/83fhB9tiKCM9MHNnHSDJdUjVcG3m)) | [Visit Preview](https://user-profile-widget-git-wait-for-refresh.preview.descope.org) | 💬 [**Add feedback**](https://vercel.live/open-feedback/user-profile-widget-git-wait-for-refresh.preview.descope.org?via=pr-comment-feedback-link) | Mar 18, 2025 5:50pm |






author_association: NONE
user: login: nx-cloud[bot]
created_at: 2025-03-17T20:31:08Z
body: 
View your [CI Pipeline Execution ↗](https://cloud.nx.app/cipes/67d9b18a83192351761a70e5?utm_source=pull-request&utm_medium=comment) for commit 8a9fb27f2b8d7bd216c170ce5b6b499a3ce5b114.

| Command | Status | Duration | Result |
|---------|--------|----------:|--------|
| `nx affected --target test:e2e` | ✅ Succeeded | 1m 13s | [View ↗](https://cloud.nx.app/runs/z1vx8c4Pjj?utm_source=pull-request&utm_medium=comment) |
| `nx affected --target test` | ✅ Succeeded | 1m 31s | [View ↗](https://cloud.nx.app/runs/s9Fxc9dgNd?utm_source=pull-request&utm_medium=comment) |
| `nx affected --target lint --fix=true` | ✅ Succeeded | 8s | [View ↗](https://cloud.nx.app/runs/T2UeTOX0I1?utm_source=pull-request&utm_medium=comment) |
| `nx affected --target build` | ✅ Succeeded | 1m 16s | [View ↗](https://cloud.nx.app/runs/vAIRX3ImAZ?utm_source=pull-request&utm_medium=comment) |
| `nx affected --target licenseCheck` | ✅ Succeeded | <1s | [View ↗](https://cloud.nx.app/runs/WXSGQHUH1C?utm_source=pull-request&utm_medium=comment) |

---

☁️ [Nx Cloud](https://cloud.nx.app?utm_source=pull-request&utm_medium=comment) last updated this comment at `2025-03-18 17:51:14` UTC

<!-- NX_CLOUD_APP_COMMENT_END -->
author_association: NONE