From d4bf28bc23e05b3372f019b5876b5057c824a340 Mon Sep 17 00:00:00 2001 From: Robert Boes <2871897+RobertBoes@users.noreply.github.com> Date: Fri, 3 Apr 2026 10:52:41 +0200 Subject: [PATCH 1/9] feat(vue): make publicKey optional in useFormRelay composable When publicKey is omitted, schema fetch is skipped. The composable provides empty reactive state for manual form building. Submit is a no-op without a schema. --- .../vue/src/composables/useFormRelay.test.ts | 27 +++++++++++++++++++ packages/vue/src/composables/useFormRelay.ts | 14 +++++----- packages/vue/src/types.ts | 2 +- 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/packages/vue/src/composables/useFormRelay.test.ts b/packages/vue/src/composables/useFormRelay.test.ts index f4982c0..f932806 100644 --- a/packages/vue/src/composables/useFormRelay.test.ts +++ b/packages/vue/src/composables/useFormRelay.test.ts @@ -449,6 +449,33 @@ describe("useFormRelay", () => { { botToken: "new-token" }, ); }); + + test("skips schema fetch when publicKey is not provided", async () => { + const { schema, schemaLoading, fields, values } = useFormRelay({ + formId: "01abc", + }); + + await nextTick(); + await nextTick(); + + expect(mockGetSchema).not.toHaveBeenCalled(); + expect(schemaLoading.value).toBe(false); + expect(schema.value).toBeNull(); + expect(fields.value).toEqual([]); + expect(Object.keys(values)).toEqual([]); + }); + + test("submit is a no-op when no schema is available", async () => { + const { submit, values, submitting } = useFormRelay({ + formId: "01abc", + }); + + values.email = "john@example.com"; + await submit(); + + expect(mockSubmit).not.toHaveBeenCalled(); + expect(submitting.value).toBe(false); + }); }); describe("auto bot protection", () => { diff --git a/packages/vue/src/composables/useFormRelay.ts b/packages/vue/src/composables/useFormRelay.ts index 68f4e7b..2a355b4 100644 --- a/packages/vue/src/composables/useFormRelay.ts +++ b/packages/vue/src/composables/useFormRelay.ts @@ -5,12 +5,12 @@ import type { BotProtectionWidget } from "@formrelay/core/bot-protection"; import type { UseFormRelayOptions, UseFormRelayReturn } from "../types"; export function useFormRelay(options: UseFormRelayOptions): UseFormRelayReturn { - const client = createForm(options.formId, { - publicKey: options.publicKey, - }); + const client = options.publicKey + ? createForm(options.formId, { publicKey: options.publicKey }) + : null; const schema = ref(null); - const schemaLoading = ref(!options.initialSchema); + const schemaLoading = ref(!options.initialSchema && !!options.publicKey); const schemaError = ref(null); const values = reactive>({}); @@ -43,7 +43,7 @@ export function useFormRelay(options: UseFormRelayOptions): UseFormRelayReturn { if (options.initialSchema) { schema.value = options.initialSchema; initializeValues(options.initialSchema); - } else { + } else if (options.publicKey) { fetchSchema(); } @@ -52,7 +52,7 @@ export function useFormRelay(options: UseFormRelayOptions): UseFormRelayReturn { schemaError.value = null; try { - const loadedSchema = await client.getSchema(); + const loadedSchema = await client!.getSchema(); schema.value = loadedSchema; initializeValues(loadedSchema); } catch (error) { @@ -86,7 +86,7 @@ export function useFormRelay(options: UseFormRelayOptions): UseFormRelayReturn { submitting.value = true; try { - const result = await client.submit( + const result = await client!.submit( { ...values }, botToken.value ? { botToken: botToken.value } : {}, ); diff --git a/packages/vue/src/types.ts b/packages/vue/src/types.ts index 4d567f8..355aa03 100644 --- a/packages/vue/src/types.ts +++ b/packages/vue/src/types.ts @@ -9,7 +9,7 @@ import type { ComputedRef, Ref } from "vue"; export interface UseFormRelayOptions { formId: string; - publicKey: string; + publicKey?: string; initialSchema?: FormSchema; botProtectionContainer?: Ref; validate?: (data: Record, schema: JsonSchema) => Record; From 8c8a8b720f5652f885bb42c12d4675f48fe6045f Mon Sep 17 00:00:00 2001 From: Robert Boes <2871897+RobertBoes@users.noreply.github.com> Date: Fri, 3 Apr 2026 10:55:14 +0200 Subject: [PATCH 2/9] feat(vue): add initialSchema, botProtectionContainer props to FormRelay publicKey is now optional. initialSchema skips the schema fetch. botProtectionContainer enables auto bot protection widget mounting. --- packages/vue/src/components/FormRelay.test.ts | 46 +++++++++++++++++++ packages/vue/src/components/FormRelay.ts | 18 ++++++-- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/packages/vue/src/components/FormRelay.test.ts b/packages/vue/src/components/FormRelay.test.ts index f34dbc6..35ab371 100644 --- a/packages/vue/src/components/FormRelay.test.ts +++ b/packages/vue/src/components/FormRelay.test.ts @@ -271,4 +271,50 @@ describe("FormRelay", () => { expect(wrapper.html()).toBe(""); }); + + test("uses initialSchema prop and skips fetch", async () => { + const { createForm } = await import("@formrelay/core"); + const mockGetSchema = vi.fn(); + vi.mocked(createForm).mockReturnValueOnce({ + getSchema: mockGetSchema, + submit: vi.fn().mockResolvedValue({ success: true, message: "OK" }), + } as any); + + let slotProps: any; + + mount(FormRelay, { + props: { formId: "01abc", publicKey: "pk_fr_test", initialSchema: mockSchema }, + slots: { + default: (props: any) => { + slotProps = props; + return h("div"); + }, + }, + }); + + await flushPromises(); + await nextTick(); + + expect(mockGetSchema).not.toHaveBeenCalled(); + expect(slotProps.schemaLoading).toBe(false); + expect(slotProps.fields).toHaveLength(1); + }); + + test("renders default slot immediately when publicKey is omitted", () => { + let slotProps: any; + + mount(FormRelay, { + props: { formId: "01abc" }, + slots: { + default: (props: any) => { + slotProps = props; + return h("div"); + }, + }, + }); + + expect(slotProps).toBeDefined(); + expect(slotProps.schemaLoading).toBe(false); + expect(slotProps.fields).toEqual([]); + }); }); diff --git a/packages/vue/src/components/FormRelay.ts b/packages/vue/src/components/FormRelay.ts index 9f93f0d..bd1b0b3 100644 --- a/packages/vue/src/components/FormRelay.ts +++ b/packages/vue/src/components/FormRelay.ts @@ -1,4 +1,4 @@ -import { defineComponent } from "vue"; +import { defineComponent, toRef } from "vue"; import { useFormRelay } from "../composables/useFormRelay"; import type { UseFormRelayOptions } from "../types"; @@ -6,13 +6,25 @@ export default defineComponent({ name: "FormRelay", props: { formId: { type: String, required: true }, - publicKey: { type: String, required: true }, + publicKey: { type: String, default: undefined }, + initialSchema: { type: Object, default: undefined }, + botProtectionContainer: { type: Object, default: undefined }, validate: { type: Function, default: undefined }, onSuccess: { type: Function, default: undefined }, onError: { type: Function, default: undefined }, }, setup(props, { slots }) { - const state = useFormRelay(props as unknown as UseFormRelayOptions); + const state = useFormRelay({ + formId: props.formId, + publicKey: props.publicKey, + initialSchema: props.initialSchema, + botProtectionContainer: props.botProtectionContainer + ? toRef(props, "botProtectionContainer") + : undefined, + validate: props.validate, + onSuccess: props.onSuccess, + onError: props.onError, + } as UseFormRelayOptions); return () => { if (state.schemaLoading.value && !state.schemaError.value && slots.loading) { From da6c89b7a15da9d8a5df5c4cbe24cb7c22760492 Mon Sep 17 00:00:00 2001 From: Robert Boes <2871897+RobertBoes@users.noreply.github.com> Date: Fri, 3 Apr 2026 10:57:05 +0200 Subject: [PATCH 3/9] feat(nuxt): replace FormRelay re-export with async component The Nuxt FormRelay component now wraps the Nuxt useFormRelay composable, providing SSR schema prefetch, auto publicKey from runtime config, and secret key support. Only formId is required as a prop. Supports #loading, #error, and #default slots. --- .../nuxt/src/runtime/components/FormRelay.ts | 59 ++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/packages/nuxt/src/runtime/components/FormRelay.ts b/packages/nuxt/src/runtime/components/FormRelay.ts index 048366b..4d07c74 100644 --- a/packages/nuxt/src/runtime/components/FormRelay.ts +++ b/packages/nuxt/src/runtime/components/FormRelay.ts @@ -1 +1,58 @@ -export { FormRelay as default } from "@formrelay/vue"; +import { defineComponent, toRef } from "vue"; +import { useFormRelay } from "../composables/useFormRelay"; + +export default defineComponent({ + name: "FormRelay", + props: { + formId: { type: String, required: true }, + publicKey: { type: String, default: undefined }, + botProtectionContainer: { type: Object, default: undefined }, + validate: { type: Function, default: undefined }, + onSuccess: { type: Function, default: undefined }, + onError: { type: Function, default: undefined }, + }, + async setup(props, { slots }) { + const state = await useFormRelay({ + formId: props.formId, + publicKey: props.publicKey, + botProtectionContainer: props.botProtectionContainer + ? toRef(props, "botProtectionContainer") + : undefined, + validate: props.validate, + onSuccess: props.onSuccess, + onError: props.onError, + }); + + return () => { + if (state.schemaLoading.value && !state.schemaError.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({ + schema: state.schema.value, + columns: state.columns.value, + fields: state.fields.value, + schemaLoading: state.schemaLoading.value, + schemaError: state.schemaError.value, + values: state.values, + errors: state.errors.value, + submitting: state.submitting.value, + submitted: state.submitted.value, + canSubmit: state.canSubmit.value, + submit: state.submit, + reset: state.reset, + setBotToken: state.setBotToken, + validationSchema: state.validationSchema.value, + botProtection: state.botProtection.value, + }); + }; + }, +}); From 3886dc79d0aef8990c23b2098ed688e216d8a3a8 Mon Sep 17 00:00:00 2001 From: Robert Boes <2871897+RobertBoes@users.noreply.github.com> Date: Fri, 3 Apr 2026 10:57:52 +0200 Subject: [PATCH 4/9] chore: add changeset for FormRelay component improvements --- .changeset/nuxt-formrelay-component.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/nuxt-formrelay-component.md diff --git a/.changeset/nuxt-formrelay-component.md b/.changeset/nuxt-formrelay-component.md new file mode 100644 index 0000000..71fd2dc --- /dev/null +++ b/.changeset/nuxt-formrelay-component.md @@ -0,0 +1,9 @@ +--- +"@formrelay/vue": minor +--- + +Make `publicKey` optional on the `` component and `useFormRelay` composable. When omitted, the schema fetch is skipped and the form renders immediately with empty state for manual form building. + +Add `initialSchema` and `botProtectionContainer` as optional props on the `` component, matching features already available on the composable. + +The Nuxt `` component is now an async component wrapping the Nuxt `useFormRelay` composable, providing SSR schema prefetch, automatic `publicKey` injection from runtime config, and secret key support. Only `formId` is required. From 5de4fbc54f1a56aff10d447f58d0baa30fc63a0b Mon Sep 17 00:00:00 2001 From: Robert Boes <2871897+RobertBoes@users.noreply.github.com> Date: Fri, 3 Apr 2026 11:04:27 +0200 Subject: [PATCH 5/9] fix(vue,nuxt): always pass botProtectionContainer as toRef The conditional check evaluated at setup time, when the template ref is still null (the element lives inside the slot which hasn't rendered yet). This caused the composable to skip setting up the watcher entirely. Always passing the toRef ensures the watcher picks up the ref when it populates after the slot renders. --- packages/nuxt/src/runtime/components/FormRelay.ts | 4 +--- packages/vue/src/components/FormRelay.ts | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/nuxt/src/runtime/components/FormRelay.ts b/packages/nuxt/src/runtime/components/FormRelay.ts index 4d07c74..8b4dd97 100644 --- a/packages/nuxt/src/runtime/components/FormRelay.ts +++ b/packages/nuxt/src/runtime/components/FormRelay.ts @@ -15,9 +15,7 @@ export default defineComponent({ const state = await useFormRelay({ formId: props.formId, publicKey: props.publicKey, - botProtectionContainer: props.botProtectionContainer - ? toRef(props, "botProtectionContainer") - : undefined, + botProtectionContainer: toRef(props, "botProtectionContainer"), validate: props.validate, onSuccess: props.onSuccess, onError: props.onError, diff --git a/packages/vue/src/components/FormRelay.ts b/packages/vue/src/components/FormRelay.ts index bd1b0b3..4926feb 100644 --- a/packages/vue/src/components/FormRelay.ts +++ b/packages/vue/src/components/FormRelay.ts @@ -18,9 +18,7 @@ export default defineComponent({ formId: props.formId, publicKey: props.publicKey, initialSchema: props.initialSchema, - botProtectionContainer: props.botProtectionContainer - ? toRef(props, "botProtectionContainer") - : undefined, + botProtectionContainer: toRef(props, "botProtectionContainer"), validate: props.validate, onSuccess: props.onSuccess, onError: props.onError, From ad0688d0481bf61f9d07c66912957a40ce77854c Mon Sep 17 00:00:00 2001 From: Robert Boes <2871897+RobertBoes@users.noreply.github.com> Date: Fri, 3 Apr 2026 11:45:18 +0200 Subject: [PATCH 6/9] refactor: address review feedback - Remove client! non-null assertions by adding explicit !client guard to submit() and fetchSchema() - Guard Nuxt composable's useAsyncData call when no publicKey exists (consistent with Vue composable behavior) - Extract shared renderFormRelay() helper to deduplicate render logic between Vue and Nuxt components --- .../nuxt/src/runtime/components/FormRelay.ts | 33 ++---------------- .../src/runtime/composables/useFormRelay.ts | 31 ++++++++++------- packages/vue/src/components/FormRelay.ts | 33 ++---------------- .../vue/src/components/renderFormRelay.ts | 34 +++++++++++++++++++ packages/vue/src/composables/useFormRelay.ts | 7 ++-- packages/vue/src/index.ts | 1 + 6 files changed, 61 insertions(+), 78 deletions(-) create mode 100644 packages/vue/src/components/renderFormRelay.ts diff --git a/packages/nuxt/src/runtime/components/FormRelay.ts b/packages/nuxt/src/runtime/components/FormRelay.ts index 8b4dd97..bb75b6c 100644 --- a/packages/nuxt/src/runtime/components/FormRelay.ts +++ b/packages/nuxt/src/runtime/components/FormRelay.ts @@ -1,4 +1,5 @@ import { defineComponent, toRef } from "vue"; +import { renderFormRelay } from "@formrelay/vue"; import { useFormRelay } from "../composables/useFormRelay"; export default defineComponent({ @@ -21,36 +22,6 @@ export default defineComponent({ onError: props.onError, }); - return () => { - if (state.schemaLoading.value && !state.schemaError.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({ - schema: state.schema.value, - columns: state.columns.value, - fields: state.fields.value, - schemaLoading: state.schemaLoading.value, - schemaError: state.schemaError.value, - values: state.values, - errors: state.errors.value, - submitting: state.submitting.value, - submitted: state.submitted.value, - canSubmit: state.canSubmit.value, - submit: state.submit, - reset: state.reset, - setBotToken: state.setBotToken, - validationSchema: state.validationSchema.value, - botProtection: state.botProtection.value, - }); - }; + return () => renderFormRelay(state, slots); }, }); diff --git a/packages/nuxt/src/runtime/composables/useFormRelay.ts b/packages/nuxt/src/runtime/composables/useFormRelay.ts index 61f558b..b1be8f0 100644 --- a/packages/nuxt/src/runtime/composables/useFormRelay.ts +++ b/packages/nuxt/src/runtime/composables/useFormRelay.ts @@ -1,8 +1,8 @@ -import { effectScope, onScopeDispose } from "vue"; +import { effectScope, onScopeDispose, type Ref } from "vue"; import { useFormRelay as useVueFormRelay } from "@formrelay/vue"; import type { UseFormRelayOptions } from "@formrelay/vue"; import { createForm } from "@formrelay/core"; -import type { HttpAdapter, HttpResponse, RequestOptions } from "@formrelay/core"; +import type { FormSchema, HttpAdapter, HttpResponse, RequestOptions } from "@formrelay/core"; import { useRuntimeConfig, useAsyncData } from "#imports"; function createSecretKeyAdapter(secretKey: string): HttpAdapter { @@ -51,23 +51,28 @@ export async function useFormRelay(options: Partial & { for const scope = effectScope(); onScopeDispose(() => scope.stop()); - const schemaClient = - import.meta.server && secretKey - ? createForm(options.formId, { - publicKey, - httpClient: createSecretKeyAdapter(secretKey), - }) - : createForm(options.formId, { publicKey }); + let initialSchema: Ref | undefined; - const { data: initialSchema } = await useAsyncData(`formrelay-schema-${options.formId}`, () => - schemaClient.getSchema(), - ); + if (publicKey) { + const schemaClient = + import.meta.server && secretKey + ? createForm(options.formId, { + publicKey, + httpClient: createSecretKeyAdapter(secretKey), + }) + : createForm(options.formId, { publicKey }); + + const asyncData = await useAsyncData(`formrelay-schema-${options.formId}`, () => + schemaClient.getSchema(), + ); + initialSchema = asyncData.data; + } return scope.run(() => useVueFormRelay({ formId: options.formId, publicKey, - initialSchema: initialSchema.value ?? undefined, + initialSchema: initialSchema?.value ?? undefined, botProtectionContainer: options.botProtectionContainer, validate: options.validate, onSuccess: options.onSuccess, diff --git a/packages/vue/src/components/FormRelay.ts b/packages/vue/src/components/FormRelay.ts index 4926feb..4bba6e4 100644 --- a/packages/vue/src/components/FormRelay.ts +++ b/packages/vue/src/components/FormRelay.ts @@ -1,5 +1,6 @@ import { defineComponent, toRef } from "vue"; import { useFormRelay } from "../composables/useFormRelay"; +import { renderFormRelay } from "./renderFormRelay"; import type { UseFormRelayOptions } from "../types"; export default defineComponent({ @@ -24,36 +25,6 @@ export default defineComponent({ onError: props.onError, } as UseFormRelayOptions); - return () => { - if (state.schemaLoading.value && !state.schemaError.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({ - schema: state.schema.value, - columns: state.columns.value, - fields: state.fields.value, - schemaLoading: state.schemaLoading.value, - schemaError: state.schemaError.value, - values: state.values, - errors: state.errors.value, - submitting: state.submitting.value, - submitted: state.submitted.value, - canSubmit: state.canSubmit.value, - submit: state.submit, - reset: state.reset, - setBotToken: state.setBotToken, - validationSchema: state.validationSchema.value, - botProtection: state.botProtection.value, - }); - }; + return () => renderFormRelay(state, slots); }, }); diff --git a/packages/vue/src/components/renderFormRelay.ts b/packages/vue/src/components/renderFormRelay.ts new file mode 100644 index 0000000..f8b8ab6 --- /dev/null +++ b/packages/vue/src/components/renderFormRelay.ts @@ -0,0 +1,34 @@ +import type { Slots, VNode } from "vue"; +import type { UseFormRelayReturn } from "../types"; + +export function renderFormRelay(state: UseFormRelayReturn, slots: Slots): VNode | VNode[] | null { + if (state.schemaLoading.value && !state.schemaError.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({ + schema: state.schema.value, + columns: state.columns.value, + fields: state.fields.value, + schemaLoading: state.schemaLoading.value, + schemaError: state.schemaError.value, + values: state.values, + errors: state.errors.value, + submitting: state.submitting.value, + submitted: state.submitted.value, + canSubmit: state.canSubmit.value, + submit: state.submit, + reset: state.reset, + setBotToken: state.setBotToken, + validationSchema: state.validationSchema.value, + botProtection: state.botProtection.value, + }); +} diff --git a/packages/vue/src/composables/useFormRelay.ts b/packages/vue/src/composables/useFormRelay.ts index 2a355b4..d6f22ff 100644 --- a/packages/vue/src/composables/useFormRelay.ts +++ b/packages/vue/src/composables/useFormRelay.ts @@ -52,7 +52,8 @@ export function useFormRelay(options: UseFormRelayOptions): UseFormRelayReturn { schemaError.value = null; try { - const loadedSchema = await client!.getSchema(); + if (!client) return; + const loadedSchema = await client.getSchema(); schema.value = loadedSchema; initializeValues(loadedSchema); } catch (error) { @@ -71,7 +72,7 @@ export function useFormRelay(options: UseFormRelayOptions): UseFormRelayReturn { } async function submit() { - if (!schema.value || !canSubmit.value) return; + if (!client || !schema.value || !canSubmit.value) return; errors.value = {}; @@ -86,7 +87,7 @@ export function useFormRelay(options: UseFormRelayOptions): UseFormRelayReturn { submitting.value = true; try { - const result = await client!.submit( + const result = await client.submit( { ...values }, botToken.value ? { botToken: botToken.value } : {}, ); diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 5f54cb6..30f6a98 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -1,4 +1,5 @@ export { useFormRelay } from "./composables/useFormRelay"; export { default as FormRelay } from "./components/FormRelay"; export { default as FormRelayGrid } from "./components/FormRelayGrid"; +export { renderFormRelay } from "./components/renderFormRelay"; export type { UseFormRelayOptions, UseFormRelayReturn } from "./types"; From f6744ab1f75816e644edad4208e937b5ecb8cd31 Mon Sep 17 00:00:00 2001 From: Robert Boes <2871897+RobertBoes@users.noreply.github.com> Date: Fri, 3 Apr 2026 11:57:41 +0200 Subject: [PATCH 7/9] refactor: address thorough review feedback - Change Nuxt module publicKey default from "" to undefined so the if (publicKey) guard works correctly for unconfigured projects - Add PropType annotations to Vue and Nuxt component props for proper template-level type safety, removing the as UseFormRelayOptions cast - Remove dead if (!client) guard inside fetchSchema (unreachable since fetchSchema is only called when publicKey is present) - Add comment to Nuxt component explaining why initialSchema is omitted - Add test: initialSchema without publicKey (display-only rendering) - Add test: botProtectionContainer prop forwarding --- packages/nuxt/src/module.ts | 2 +- .../nuxt/src/runtime/components/FormRelay.ts | 27 ++++++++++--- .../src/runtime/composables/useFormRelay.ts | 2 +- packages/vue/src/components/FormRelay.test.ts | 38 ++++++++++++++++++- packages/vue/src/components/FormRelay.ts | 29 ++++++++++---- .../vue/src/composables/useFormRelay.test.ts | 14 +++++++ packages/vue/src/composables/useFormRelay.ts | 3 +- 7 files changed, 98 insertions(+), 17 deletions(-) diff --git a/packages/nuxt/src/module.ts b/packages/nuxt/src/module.ts index 9d20df0..64a2ddc 100644 --- a/packages/nuxt/src/module.ts +++ b/packages/nuxt/src/module.ts @@ -15,7 +15,7 @@ export default defineNuxtModule({ const resolver = createResolver(import.meta.url); nuxt.options.runtimeConfig.public.formrelay = { - publicKey: options.publicKey ?? "", + publicKey: options.publicKey, }; if (options.secretKey) { diff --git a/packages/nuxt/src/runtime/components/FormRelay.ts b/packages/nuxt/src/runtime/components/FormRelay.ts index bb75b6c..7a98962 100644 --- a/packages/nuxt/src/runtime/components/FormRelay.ts +++ b/packages/nuxt/src/runtime/components/FormRelay.ts @@ -1,16 +1,33 @@ -import { defineComponent, toRef } from "vue"; +import { defineComponent, toRef, type PropType, type Ref } from "vue"; import { renderFormRelay } from "@formrelay/vue"; +import type { FormRelayError, JsonSchema } from "@formrelay/core"; import { useFormRelay } from "../composables/useFormRelay"; +// No initialSchema prop — the Nuxt composable handles SSR schema +// prefetch internally via useAsyncData. export default defineComponent({ name: "FormRelay", props: { formId: { type: String, required: true }, publicKey: { type: String, default: undefined }, - botProtectionContainer: { type: Object, default: undefined }, - validate: { type: Function, default: undefined }, - onSuccess: { type: Function, default: undefined }, - onError: { type: Function, default: undefined }, + botProtectionContainer: { + type: Object as PropType>, + default: undefined, + }, + validate: { + type: Function as PropType< + (data: Record, schema: JsonSchema) => Record + >, + default: undefined, + }, + onSuccess: { + type: Function as PropType<(result: { message: string }) => void>, + default: undefined, + }, + onError: { + type: Function as PropType<(error: FormRelayError) => void>, + default: undefined, + }, }, async setup(props, { slots }) { const state = await useFormRelay({ diff --git a/packages/nuxt/src/runtime/composables/useFormRelay.ts b/packages/nuxt/src/runtime/composables/useFormRelay.ts index b1be8f0..2db6ac1 100644 --- a/packages/nuxt/src/runtime/composables/useFormRelay.ts +++ b/packages/nuxt/src/runtime/composables/useFormRelay.ts @@ -36,7 +36,7 @@ function createSecretKeyAdapter(secretKey: string): HttpAdapter { export async function useFormRelay(options: Partial & { formId: string }) { const runtimeConfig = useRuntimeConfig(); const config = runtimeConfig.public.formrelay as { - publicKey: string; + publicKey?: string; }; const secretKey = (runtimeConfig as Record).formrelaySecretKey as diff --git a/packages/vue/src/components/FormRelay.test.ts b/packages/vue/src/components/FormRelay.test.ts index 35ab371..53bb803 100644 --- a/packages/vue/src/components/FormRelay.test.ts +++ b/packages/vue/src/components/FormRelay.test.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { describe, expect, test, vi } from "vitest"; import { mount, flushPromises } from "@vue/test-utils"; -import { h, nextTick } from "vue"; +import { h, nextTick, ref } from "vue"; import FormRelay from "./FormRelay"; import { FormRelayError } from "@formrelay/core"; @@ -300,6 +300,42 @@ describe("FormRelay", () => { expect(slotProps.fields).toHaveLength(1); }); + test("forwards botProtectionContainer as reactive ref to composable", async () => { + const containerRef = ref(null); + + const mockSchemaWithBot = { + ...mockSchema, + botProtection: { type: "turnstile" as const, siteKey: "0x-key" }, + }; + + const { createForm } = await import("@formrelay/core"); + vi.mocked(createForm).mockReturnValueOnce({ + getSchema: vi.fn().mockResolvedValue(mockSchemaWithBot), + submit: vi.fn().mockResolvedValue({ success: true, message: "OK" }), + } as any); + + let slotProps: any; + + mount(FormRelay, { + props: { + formId: "01abc", + publicKey: "pk_fr_test", + botProtectionContainer: containerRef, + }, + slots: { + default: (props: any) => { + slotProps = props; + return h("div"); + }, + }, + }); + + await flushPromises(); + await nextTick(); + + expect(slotProps.botProtection).toEqual({ type: "turnstile", siteKey: "0x-key" }); + }); + test("renders default slot immediately when publicKey is omitted", () => { let slotProps: any; diff --git a/packages/vue/src/components/FormRelay.ts b/packages/vue/src/components/FormRelay.ts index 4bba6e4..d8087f1 100644 --- a/packages/vue/src/components/FormRelay.ts +++ b/packages/vue/src/components/FormRelay.ts @@ -1,18 +1,33 @@ -import { defineComponent, toRef } from "vue"; +import { defineComponent, toRef, type PropType, type Ref } from "vue"; import { useFormRelay } from "../composables/useFormRelay"; import { renderFormRelay } from "./renderFormRelay"; import type { UseFormRelayOptions } from "../types"; +import type { FormRelayError, FormSchema, JsonSchema } from "@formrelay/core"; export default defineComponent({ name: "FormRelay", props: { formId: { type: String, required: true }, publicKey: { type: String, default: undefined }, - initialSchema: { type: Object, default: undefined }, - botProtectionContainer: { type: Object, default: undefined }, - validate: { type: Function, default: undefined }, - onSuccess: { type: Function, default: undefined }, - onError: { type: Function, default: undefined }, + initialSchema: { type: Object as PropType, default: undefined }, + botProtectionContainer: { + type: Object as PropType>, + default: undefined, + }, + validate: { + type: Function as PropType< + (data: Record, schema: JsonSchema) => Record + >, + default: undefined, + }, + onSuccess: { + type: Function as PropType<(result: { message: string }) => void>, + default: undefined, + }, + onError: { + type: Function as PropType<(error: FormRelayError) => void>, + default: undefined, + }, }, setup(props, { slots }) { const state = useFormRelay({ @@ -23,7 +38,7 @@ export default defineComponent({ validate: props.validate, onSuccess: props.onSuccess, onError: props.onError, - } as UseFormRelayOptions); + }); return () => renderFormRelay(state, slots); }, diff --git a/packages/vue/src/composables/useFormRelay.test.ts b/packages/vue/src/composables/useFormRelay.test.ts index f932806..2808427 100644 --- a/packages/vue/src/composables/useFormRelay.test.ts +++ b/packages/vue/src/composables/useFormRelay.test.ts @@ -476,6 +476,20 @@ describe("useFormRelay", () => { expect(mockSubmit).not.toHaveBeenCalled(); expect(submitting.value).toBe(false); }); + + test("uses initialSchema without publicKey for display-only rendering", () => { + const { schema, schemaLoading, fields, values } = useFormRelay({ + formId: "01abc", + initialSchema: mockSchema, + }); + + expect(mockGetSchema).not.toHaveBeenCalled(); + expect(schemaLoading.value).toBe(false); + expect(schema.value).toEqual(mockSchema); + expect(fields.value).toHaveLength(2); + expect(values.email).toBe(""); + expect(values.name).toBe(""); + }); }); describe("auto bot protection", () => { diff --git a/packages/vue/src/composables/useFormRelay.ts b/packages/vue/src/composables/useFormRelay.ts index d6f22ff..9a2b735 100644 --- a/packages/vue/src/composables/useFormRelay.ts +++ b/packages/vue/src/composables/useFormRelay.ts @@ -52,8 +52,7 @@ export function useFormRelay(options: UseFormRelayOptions): UseFormRelayReturn { schemaError.value = null; try { - if (!client) return; - const loadedSchema = await client.getSchema(); + const loadedSchema = await client!.getSchema(); schema.value = loadedSchema; initializeValues(loadedSchema); } catch (error) { From 2333747489a90d61592a288e66169b2f3a619cc5 Mon Sep 17 00:00:00 2001 From: Robert Boes <2871897+RobertBoes@users.noreply.github.com> Date: Fri, 3 Apr 2026 12:20:16 +0200 Subject: [PATCH 8/9] fix(vue): remove unused UseFormRelayOptions import --- packages/vue/src/components/FormRelay.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/vue/src/components/FormRelay.ts b/packages/vue/src/components/FormRelay.ts index d8087f1..f6e1113 100644 --- a/packages/vue/src/components/FormRelay.ts +++ b/packages/vue/src/components/FormRelay.ts @@ -1,7 +1,6 @@ import { defineComponent, toRef, type PropType, type Ref } from "vue"; import { useFormRelay } from "../composables/useFormRelay"; import { renderFormRelay } from "./renderFormRelay"; -import type { UseFormRelayOptions } from "../types"; import type { FormRelayError, FormSchema, JsonSchema } from "@formrelay/core"; export default defineComponent({ From 198e2e71e01629c152c93ff62c26cb24e6fe047f Mon Sep 17 00:00:00 2001 From: Robert Boes <2871897+RobertBoes@users.noreply.github.com> Date: Fri, 3 Apr 2026 12:27:25 +0200 Subject: [PATCH 9/9] chore: update changeset to include @formrelay/nuxt and reflect full scope --- .changeset/nuxt-formrelay-component.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.changeset/nuxt-formrelay-component.md b/.changeset/nuxt-formrelay-component.md index 71fd2dc..140b005 100644 --- a/.changeset/nuxt-formrelay-component.md +++ b/.changeset/nuxt-formrelay-component.md @@ -1,9 +1,12 @@ --- "@formrelay/vue": minor +"@formrelay/nuxt": minor --- Make `publicKey` optional on the `` component and `useFormRelay` composable. When omitted, the schema fetch is skipped and the form renders immediately with empty state for manual form building. -Add `initialSchema` and `botProtectionContainer` as optional props on the `` component, matching features already available on the composable. +Add `initialSchema` and `botProtectionContainer` as optional props on the Vue `` component, matching features already available on the composable. All props now use `PropType` for proper template-level type safety. -The Nuxt `` component is now an async component wrapping the Nuxt `useFormRelay` composable, providing SSR schema prefetch, automatic `publicKey` injection from runtime config, and secret key support. Only `formId` is required. +Extract shared `renderFormRelay()` helper for consistent slot rendering across packages. + +The Nuxt `` component is now an async component wrapping the Nuxt `useFormRelay` composable, providing SSR schema prefetch, automatic `publicKey` injection from runtime config, and secret key support. Only `formId` is required. The Nuxt composable now correctly skips the schema fetch when no `publicKey` is configured.