Fix Plaid link script loading and first-sync account linking (#1165)

* fix: Handle conditional loading of Plaid Link script

* fix: Plaid accounts not linking on first sync

* fix: Handle Plaid script loading edge cases

* fix: Use connection token for disconnect safety and retry failed script loads

* fix: Destroy Plaid Link handler on controller disconnect

* fix: Add timeout to Plaid CDN script loader to prevent deadlocks
This commit is contained in:
Chase Martin
2026-03-13 03:11:51 -04:00
committed by GitHub
parent 3adc011df0
commit 50f3a5c030
4 changed files with 78 additions and 16 deletions

View File

@@ -10,11 +10,75 @@ export default class extends Controller {
};
connect() {
this.open();
this._connectionToken = (this._connectionToken ?? 0) + 1;
const connectionToken = this._connectionToken;
this.open(connectionToken).catch((error) => {
console.error("Failed to initialize Plaid Link", error);
});
}
open() {
const handler = Plaid.create({
disconnect() {
this._handler?.destroy();
this._handler = null;
this._connectionToken = (this._connectionToken ?? 0) + 1;
}
waitForPlaid() {
if (typeof Plaid !== "undefined") {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
let plaidScript = document.querySelector(
'script[src*="link-initialize.js"]'
);
// Reject if the CDN request stalls without firing load or error
const timeoutId = window.setTimeout(() => {
if (plaidScript) plaidScript.dataset.plaidState = "error";
reject(new Error("Timed out loading Plaid script"));
}, 10_000);
// Remove previously failed script so we can retry with a fresh element
if (plaidScript?.dataset.plaidState === "error") {
plaidScript.remove();
plaidScript = null;
}
if (!plaidScript) {
plaidScript = document.createElement("script");
plaidScript.src = "https://cdn.plaid.com/link/v2/stable/link-initialize.js";
plaidScript.async = true;
plaidScript.dataset.plaidState = "loading";
document.head.appendChild(plaidScript);
}
plaidScript.addEventListener("load", () => {
window.clearTimeout(timeoutId);
plaidScript.dataset.plaidState = "loaded";
resolve();
}, { once: true });
plaidScript.addEventListener("error", () => {
window.clearTimeout(timeoutId);
plaidScript.dataset.plaidState = "error";
reject(new Error("Failed to load Plaid script"));
}, { once: true });
// Re-check after attaching listeners in case the script loaded between
// the initial typeof check and listener attachment (avoids a permanently
// pending promise on retry flows).
if (typeof Plaid !== "undefined") {
window.clearTimeout(timeoutId);
resolve();
}
});
}
async open(connectionToken = this._connectionToken) {
await this.waitForPlaid();
if (connectionToken !== this._connectionToken) return;
this._handler = Plaid.create({
token: this.linkTokenValue,
onSuccess: this.handleSuccess,
onLoad: this.handleLoad,
@@ -22,7 +86,7 @@ export default class extends Controller {
onEvent: this.handleEvent,
});
handler.open();
this._handler.open();
}
handleSuccess = (public_token, metadata) => {

View File

@@ -12,8 +12,16 @@ class PlaidItem::Syncer
sync.update!(status_text: "Importing accounts from Plaid...") if sync.respond_to?(:status_text)
plaid_item.import_latest_plaid_data
# Phase 2: Collect setup statistics
# Phase 2: Process the raw Plaid data and create/update internal domain objects
# This must happen before the linked/unlinked check because process_accounts
# is what creates Account and AccountProvider records for new PlaidAccounts.
sync.update!(status_text: "Processing accounts...") if sync.respond_to?(:status_text)
mark_import_started(sync)
plaid_item.process_accounts
# Phase 3: Collect setup statistics (now that accounts have been processed)
sync.update!(status_text: "Checking account configuration...") if sync.respond_to?(:status_text)
plaid_item.plaid_accounts.reload
collect_setup_stats(sync, provider_accounts: plaid_item.plaid_accounts)
# Check for unlinked accounts and update pending_account_setup flag
@@ -25,14 +33,9 @@ class PlaidItem::Syncer
plaid_item.update!(pending_account_setup: false) if plaid_item.respond_to?(:pending_account_setup=)
end
# Phase 3: Process the raw Plaid data and updates internal domain objects
# Phase 4: Schedule balance calculations for linked accounts
linked_accounts = plaid_item.plaid_accounts.select { |pa| pa.current_account.present? }
if linked_accounts.any?
sync.update!(status_text: "Processing transactions...") if sync.respond_to?(:status_text)
mark_import_started(sync)
plaid_item.process_accounts
# Phase 4: Schedule balance calculations
sync.update!(status_text: "Calculating balances...") if sync.respond_to?(:status_text)
plaid_item.schedule_account_syncs(
parent_sync: sync,

View File

@@ -8,7 +8,6 @@
<%= combobox_style_tag %>
<%= yield :plaid_link %>
<%= javascript_importmap_tags %>
<%= render "layouts/dark_mode_check" %>
<%= turbo_refreshes_with method: :morph, scroll: :preserve %>

View File

@@ -1,9 +1,5 @@
<%# locals: (link_token:, region:, item_id:, is_update: false) %>
<% content_for :plaid_link, flush: true do %>
<%= javascript_include_tag "https://cdn.plaid.com/link/v2/stable/link-initialize.js" %>
<% end %>
<%= tag.div data: {
controller: "plaid",
plaid_link_token_value: link_token,