From 08e8080f4f71f316f574ec27401d99e829ec8efa Mon Sep 17 00:00:00 2001 From: Robert Boes <2871897+RobertBoes@users.noreply.github.com> Date: Fri, 3 Apr 2026 09:49:09 +0200 Subject: [PATCH 1/4] feat(vue): add loading and error slots to FormRelay component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add optional #loading and #error named slots so consumers can show placeholder content while the schema fetches and handle fetch failures without manual v-if checks on schemaLoading/schemaError. Fully backwards compatible — omitting the slots preserves existing default-slot-only behavior. --- .changeset/loading-error-slots.md | 5 + packages/vue/src/components/FormRelay.test.ts | 95 +++++++++++++++++++ packages/vue/src/components/FormRelay.ts | 10 ++ 3 files changed, 110 insertions(+) create mode 100644 .changeset/loading-error-slots.md diff --git a/.changeset/loading-error-slots.md b/.changeset/loading-error-slots.md new file mode 100644 index 0000000..c94b4ec --- /dev/null +++ b/.changeset/loading-error-slots.md @@ -0,0 +1,5 @@ +--- +"@formrelay/vue": minor +--- + +Add optional `#loading` and `#error` named slots to the `` component. The `#loading` slot renders while the schema is being fetched, and the `#error` slot renders when the schema fetch fails (with `{ error }` as slot props). Both are fully backwards compatible — when omitted, the default slot receives `schemaLoading` and `schemaError` as before. diff --git a/packages/vue/src/components/FormRelay.test.ts b/packages/vue/src/components/FormRelay.test.ts index a6849db..e88b42f 100644 --- a/packages/vue/src/components/FormRelay.test.ts +++ b/packages/vue/src/components/FormRelay.test.ts @@ -100,4 +100,99 @@ describe("FormRelay", () => { expect(wrapper.find("#test-content").exists()).toBe(true); expect(wrapper.find("#test-content").text()).toBe("hello"); }); + + test("renders loading slot while schema is loading", () => { + const wrapper = mount(FormRelay, { + props: { formId: "01abc", publicKey: "pk_fr_test" }, + slots: { + loading: () => h("div", { id: "loading" }, "Loading..."), + default: () => h("div", { id: "form" }, "form content"), + }, + }); + + expect(wrapper.find("#loading").exists()).toBe(true); + expect(wrapper.find("#form").exists()).toBe(false); + }); + + test("renders default slot after schema loads when loading slot is provided", async () => { + const wrapper = mount(FormRelay, { + props: { formId: "01abc", publicKey: "pk_fr_test" }, + slots: { + loading: () => h("div", { id: "loading" }, "Loading..."), + default: () => h("div", { id: "form" }, "form content"), + }, + }); + + await flushPromises(); + await nextTick(); + + expect(wrapper.find("#loading").exists()).toBe(false); + expect(wrapper.find("#form").exists()).toBe(true); + }); + + test("renders default slot during loading when no loading slot is provided", () => { + let slotProps: any; + + mount(FormRelay, { + props: { formId: "01abc", publicKey: "pk_fr_test" }, + slots: { + default: (props: any) => { + slotProps = props; + return h("div"); + }, + }, + }); + + expect(slotProps).toBeDefined(); + expect(slotProps.schemaLoading).toBe(true); + }); + + test("renders error slot when schema fetch fails", async () => { + const { createForm } = await import("@formrelay/core"); + vi.mocked(createForm).mockReturnValueOnce({ + getSchema: vi.fn().mockRejectedValue(new Error("Network error")), + submit: vi.fn(), + } as any); + + const wrapper = mount(FormRelay, { + props: { formId: "01abc", publicKey: "pk_fr_test" }, + slots: { + error: (props: any) => h("div", { id: "error" }, props.error.detail), + default: () => h("div", { id: "form" }, "form content"), + }, + }); + + await flushPromises(); + await nextTick(); + + expect(wrapper.find("#error").exists()).toBe(true); + expect(wrapper.find("#error").text()).toBe("Network error"); + expect(wrapper.find("#form").exists()).toBe(false); + }); + + test("renders default slot with schemaError when no error slot is provided", async () => { + const { createForm } = await import("@formrelay/core"); + vi.mocked(createForm).mockReturnValueOnce({ + getSchema: vi.fn().mockRejectedValue(new Error("Network error")), + submit: vi.fn(), + } as any); + + let slotProps: any; + + mount(FormRelay, { + props: { formId: "01abc", publicKey: "pk_fr_test" }, + slots: { + default: (props: any) => { + slotProps = props; + return h("div"); + }, + }, + }); + + await flushPromises(); + await nextTick(); + + expect(slotProps.schemaError).toBeDefined(); + expect(slotProps.schemaError.detail).toBe("Network error"); + }); }); diff --git a/packages/vue/src/components/FormRelay.ts b/packages/vue/src/components/FormRelay.ts index fb7405c..90672e1 100644 --- a/packages/vue/src/components/FormRelay.ts +++ b/packages/vue/src/components/FormRelay.ts @@ -15,6 +15,16 @@ export default defineComponent({ const state = useFormRelay(props as unknown as UseFormRelayOptions); return () => { + if (state.schemaLoading.value && slots.loading) { + return slots.loading(); + } + + if (state.schemaError.value && slots.error) { + return slots.error({ + error: state.schemaError.value, + }); + } + if (!slots.default) return null; return slots.default({ From 65f314d2a1527688b07d65d43c064ad43bc48acc Mon Sep 17 00:00:00 2001 From: Robert Boes <2871897+RobertBoes@users.noreply.github.com> Date: Fri, 3 Apr 2026 09:49:41 +0200 Subject: [PATCH 2/4] chore: include @formrelay/nuxt in changeset --- .changeset/loading-error-slots.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/loading-error-slots.md b/.changeset/loading-error-slots.md index c94b4ec..3f6e4b7 100644 --- a/.changeset/loading-error-slots.md +++ b/.changeset/loading-error-slots.md @@ -1,5 +1,6 @@ --- "@formrelay/vue": minor +"@formrelay/nuxt": minor --- Add optional `#loading` and `#error` named slots to the `` component. The `#loading` slot renders while the schema is being fetched, and the `#error` slot renders when the schema fetch fails (with `{ error }` as slot props). Both are fully backwards compatible — when omitted, the default slot receives `schemaLoading` and `schemaError` as before. From 77c7cab8dc3aafa978dc3119bb8db613dd8ec46c Mon Sep 17 00:00:00 2001 From: Robert Boes <2871897+RobertBoes@users.noreply.github.com> Date: Fri, 3 Apr 2026 09:50:38 +0200 Subject: [PATCH 3/4] chore: switch changesets from linked to fixed versioning Use fixed versioning so all @formrelay/* packages stay at the same version without needing to list unchanged packages in changesets. Also remove core from the changeset since fixed handles it automatically. --- .changeset/config.json | 4 ++-- .changeset/loading-error-slots.md | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.changeset/config.json b/.changeset/config.json index 9893a31..09d1aad 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -2,8 +2,8 @@ "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json", "changelog": "@changesets/cli/changelog", "commit": false, - "fixed": [], - "linked": [["@formrelay/*"]], + "fixed": [["@formrelay/*"]], + "linked": [], "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", diff --git a/.changeset/loading-error-slots.md b/.changeset/loading-error-slots.md index 3f6e4b7..c94b4ec 100644 --- a/.changeset/loading-error-slots.md +++ b/.changeset/loading-error-slots.md @@ -1,6 +1,5 @@ --- "@formrelay/vue": minor -"@formrelay/nuxt": minor --- Add optional `#loading` and `#error` named slots to the `` component. The `#loading` slot renders while the schema is being fetched, and the `#error` slot renders when the schema fetch fails (with `{ error }` as slot props). Both are fully backwards compatible — when omitted, the default slot receives `schemaLoading` and `schemaError` as before. From 37be6885204d852dc60bdc801827ab36c65ecfce Mon Sep 17 00:00:00 2001 From: Robert Boes <2871897+RobertBoes@users.noreply.github.com> Date: Fri, 3 Apr 2026 10:03:37 +0200 Subject: [PATCH 4/4] fix(vue): add defensive guard for loading/error slot precedence Ensure error slot takes priority over loading slot if both states are truthy simultaneously. Also adds tests for loading-to-error transition, FormRelayError pass-through, and no-slots rendering. --- packages/vue/src/components/FormRelay.test.ts | 76 +++++++++++++++++++ packages/vue/src/components/FormRelay.ts | 2 +- 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/packages/vue/src/components/FormRelay.test.ts b/packages/vue/src/components/FormRelay.test.ts index e88b42f..f34dbc6 100644 --- a/packages/vue/src/components/FormRelay.test.ts +++ b/packages/vue/src/components/FormRelay.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test, vi } from "vitest"; import { mount, flushPromises } from "@vue/test-utils"; import { h, nextTick } from "vue"; import FormRelay from "./FormRelay"; +import { FormRelayError } from "@formrelay/core"; const mockSchema = { id: "01abc", @@ -195,4 +196,79 @@ describe("FormRelay", () => { expect(slotProps.schemaError).toBeDefined(); expect(slotProps.schemaError.detail).toBe("Network error"); }); + + test("transitions from loading slot to error slot when fetch fails", async () => { + const { createForm } = await import("@formrelay/core"); + vi.mocked(createForm).mockReturnValueOnce({ + getSchema: vi.fn().mockRejectedValue(new Error("Network error")), + submit: vi.fn(), + } as any); + + const wrapper = mount(FormRelay, { + props: { formId: "01abc", publicKey: "pk_fr_test" }, + slots: { + loading: () => h("div", { id: "loading" }, "Loading..."), + error: (props: any) => h("div", { id: "error" }, props.error.detail), + default: () => h("div", { id: "form" }, "form content"), + }, + }); + + expect(wrapper.find("#loading").exists()).toBe(true); + expect(wrapper.find("#error").exists()).toBe(false); + + await flushPromises(); + await nextTick(); + + expect(wrapper.find("#loading").exists()).toBe(false); + expect(wrapper.find("#error").exists()).toBe(true); + expect(wrapper.find("#error").text()).toBe("Network error"); + expect(wrapper.find("#form").exists()).toBe(false); + }); + + test("renders error slot with FormRelayError passed through directly", async () => { + const schemaError = new FormRelayError({ + type: "https://formrelay.app/errors#not-found", + title: "Not Found", + status: 404, + detail: "Form not found", + }); + + const { createForm } = await import("@formrelay/core"); + vi.mocked(createForm).mockReturnValueOnce({ + getSchema: vi.fn().mockRejectedValue(schemaError), + submit: vi.fn(), + } as any); + + let errorProps: any; + + const wrapper = mount(FormRelay, { + props: { formId: "01abc", publicKey: "pk_fr_test" }, + slots: { + error: (props: any) => { + errorProps = props; + return h("div", { id: "error" }, props.error.detail); + }, + default: () => h("div", { id: "form" }), + }, + }); + + await flushPromises(); + await nextTick(); + + expect(wrapper.find("#error").exists()).toBe(true); + expect(errorProps.error).toBe(schemaError); + expect(errorProps.error.status).toBe(404); + expect(errorProps.error.title).toBe("Not Found"); + }); + + test("renders nothing when no slots are provided", async () => { + const wrapper = mount(FormRelay, { + props: { formId: "01abc", publicKey: "pk_fr_test" }, + }); + + await flushPromises(); + await nextTick(); + + expect(wrapper.html()).toBe(""); + }); }); diff --git a/packages/vue/src/components/FormRelay.ts b/packages/vue/src/components/FormRelay.ts index 90672e1..9f93f0d 100644 --- a/packages/vue/src/components/FormRelay.ts +++ b/packages/vue/src/components/FormRelay.ts @@ -15,7 +15,7 @@ export default defineComponent({ const state = useFormRelay(props as unknown as UseFormRelayOptions); return () => { - if (state.schemaLoading.value && slots.loading) { + if (state.schemaLoading.value && !state.schemaError.value && slots.loading) { return slots.loading(); }