From 6c044458ca8f648908ce1ac39888de23328ef4eb Mon Sep 17 00:00:00 2001 From: David Barroso Date: Mon, 17 Nov 2025 08:37:26 +0100 Subject: [PATCH] fix(packages/nhost-js): react native needs special treatment when using FormData (#3697) Co-authored-by: dbarrosop --- .../tables/auth_refresh_token_types.yaml | 10 ++++- examples/tutorials/backend/nhost/nhost.toml | 4 +- .../nhost-reactnative-tutorial/app/files.tsx | 5 +++ .../nhost-reactnative-tutorial/app/todos.tsx | 7 ++- .../nhost-reactnative-tutorial/package.json | 2 + .../nhost-reactnative-tutorial/pnpm-lock.yaml | 15 +++++++ packages/nhost-js/src/storage/client.ts | 44 ++++++++++++++----- .../processor/testdata/methods_ref.yaml.ts | 44 +++++++++++++++---- .../typescript/templates/client.tmpl | 41 +++++++++++++---- 9 files changed, 140 insertions(+), 32 deletions(-) diff --git a/examples/tutorials/backend/nhost/metadata/databases/default/tables/auth_refresh_token_types.yaml b/examples/tutorials/backend/nhost/metadata/databases/default/tables/auth_refresh_token_types.yaml index 8abaa8604..2f1da7a02 100644 --- a/examples/tutorials/backend/nhost/metadata/databases/default/tables/auth_refresh_token_types.yaml +++ b/examples/tutorials/backend/nhost/metadata/databases/default/tables/auth_refresh_token_types.yaml @@ -3,8 +3,14 @@ table: schema: auth is_enum: true configuration: - column_config: {} - custom_column_names: {} + column_config: + comment: + custom_name: comment + value: + custom_name: value + custom_column_names: + comment: comment + value: value custom_name: authRefreshTokenTypes custom_root_fields: delete: deleteAuthRefreshTokenTypes diff --git a/examples/tutorials/backend/nhost/nhost.toml b/examples/tutorials/backend/nhost/nhost.toml index 252aa5864..06363f83b 100644 --- a/examples/tutorials/backend/nhost/nhost.toml +++ b/examples/tutorials/backend/nhost/nhost.toml @@ -31,7 +31,7 @@ httpPoolSize = 100 version = 22 [auth] -version = '0.41.1' +version = '0.43.1' [auth.elevatedPrivileges] mode = 'disabled' @@ -183,7 +183,7 @@ capacity = 1 [provider] [storage] -version = '0.8.0-beta5' +version = '0.9.1' [observability] [observability.grafana] diff --git a/examples/tutorials/nhost-reactnative-tutorial/app/files.tsx b/examples/tutorials/nhost-reactnative-tutorial/app/files.tsx index bdb103ff9..29975642b 100644 --- a/examples/tutorials/nhost-reactnative-tutorial/app/files.tsx +++ b/examples/tutorials/nhost-reactnative-tutorial/app/files.tsx @@ -154,6 +154,11 @@ export default function Files() { const response = await nhost.storage.uploadFiles({ "bucket-id": "personal", "file[]": [file as File], + "metadata[]": [ + { + metadata: { key1: "value1" }, + }, + ], }); // Get the processed file data diff --git a/examples/tutorials/nhost-reactnative-tutorial/app/todos.tsx b/examples/tutorials/nhost-reactnative-tutorial/app/todos.tsx index 777b7667d..dac12db8f 100644 --- a/examples/tutorials/nhost-reactnative-tutorial/app/todos.tsx +++ b/examples/tutorials/nhost-reactnative-tutorial/app/todos.tsx @@ -467,7 +467,12 @@ export default function Todos() { )} {showAddForm && ( - + Add New Todo diff --git a/examples/tutorials/nhost-reactnative-tutorial/package.json b/examples/tutorials/nhost-reactnative-tutorial/package.json index f90a3b048..e55eb4266 100644 --- a/examples/tutorials/nhost-reactnative-tutorial/package.json +++ b/examples/tutorials/nhost-reactnative-tutorial/package.json @@ -17,9 +17,11 @@ "expo-crypto": "14", "expo-document-picker": "13", "expo-file-system": "18", + "expo-linking": "^8.0.8", "expo-router": "~6", "expo-sharing": "13", "expo-status-bar": "~3.0.8", + "metro-minify-terser": "^0.83.3", "react": "19.1.0", "react-native": "0.81.4" }, diff --git a/examples/tutorials/nhost-reactnative-tutorial/pnpm-lock.yaml b/examples/tutorials/nhost-reactnative-tutorial/pnpm-lock.yaml index 2390e1376..75303a6c6 100644 --- a/examples/tutorials/nhost-reactnative-tutorial/pnpm-lock.yaml +++ b/examples/tutorials/nhost-reactnative-tutorial/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: expo-file-system: specifier: '18' version: 18.1.11(expo@54.0.9)(react-native@0.81.4(@babel/core@7.28.4)(@types/react@19.1.13)(react@19.1.0)) + expo-linking: + specifier: ^8.0.8 + version: 8.0.8(expo@54.0.9)(react-native@0.81.4(@babel/core@7.28.4)(@types/react@19.1.13)(react@19.1.0))(react@19.1.0) expo-router: specifier: ~6 version: 6.0.7(@expo/metro-runtime@6.1.2)(@types/react@19.1.13)(expo-constants@18.0.9)(expo-linking@8.0.8)(expo@54.0.9)(react-dom@19.1.1(react@19.1.0))(react-native-safe-area-context@5.6.1(react-native@0.81.4(@babel/core@7.28.4)(@types/react@19.1.13)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.4(@babel/core@7.28.4)(@types/react@19.1.13)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.28.4)(@types/react@19.1.13)(react@19.1.0))(react@19.1.0) @@ -41,6 +44,9 @@ importers: expo-status-bar: specifier: ~3.0.8 version: 3.0.8(react-native@0.81.4(@babel/core@7.28.4)(@types/react@19.1.13)(react@19.1.0))(react@19.1.0) + metro-minify-terser: + specifier: ^0.83.3 + version: 0.83.3 react: specifier: 19.1.0 version: 19.1.0 @@ -2232,6 +2238,10 @@ packages: resolution: {integrity: sha512-zvIxnh7U0JQ7vT4quasKsijId3dOAWgq+ip2jF/8TMrPUqQabGrs04L2dd0haQJ+PA+d4VvK/bPOY8X/vL2PWw==} engines: {node: '>=20.19.4'} + metro-minify-terser@0.83.3: + resolution: {integrity: sha512-O2BmfWj6FSfzBLrNCXt/rr2VYZdX5i6444QJU0fFoc7Ljg+Q+iqebwE3K0eTvkI6TRjELsXk1cjU+fXwAR4OjQ==} + engines: {node: '>=20.19.4'} + metro-resolver@0.83.1: resolution: {integrity: sha512-t8j46kiILAqqFS5RNa+xpQyVjULxRxlvMidqUswPEk5nQVNdlJslqizDm/Et3v/JKwOtQGkYAQCHxP1zGStR/g==} engines: {node: '>=20.19.4'} @@ -5840,6 +5850,11 @@ snapshots: flow-enums-runtime: 0.0.6 terser: 5.44.0 + metro-minify-terser@0.83.3: + dependencies: + flow-enums-runtime: 0.0.6 + terser: 5.44.0 + metro-resolver@0.83.1: dependencies: flow-enums-runtime: 0.0.6 diff --git a/packages/nhost-js/src/storage/client.ts b/packages/nhost-js/src/storage/client.ts index 4a1243e96..b1b01e3af 100644 --- a/packages/nhost-js/src/storage/client.ts +++ b/packages/nhost-js/src/storage/client.ts @@ -632,16 +632,27 @@ export const createAPIClient = ( ): Promise> => { const url = `${baseURL}/files`; const formData = new FormData(); + const isReactNative = + typeof navigator !== "undefined" && + (navigator as { product?: string }).product === "ReactNative"; if (body["bucket-id"] !== undefined) { formData.append("bucket-id", body["bucket-id"]); } if (body["metadata[]"] !== undefined) { body["metadata[]"].forEach((value) => { - formData.append( - "metadata[]", - new Blob([JSON.stringify(value)], { type: "application/json" }), - "", - ); + if (isReactNative) { + formData.append("metadata[]", { + string: JSON.stringify(value), + type: "application/json", + name: "", + } as unknown as Blob); + } else { + formData.append( + "metadata[]", + new Blob([JSON.stringify(value)], { type: "application/json" }), + "", + ); + } }); } if (body["file[]"] !== undefined) { @@ -799,14 +810,25 @@ export const createAPIClient = ( ): Promise> => { const url = `${baseURL}/files/${id}`; const formData = new FormData(); + const isReactNative = + typeof navigator !== "undefined" && + (navigator as { product?: string }).product === "ReactNative"; if (body["metadata"] !== undefined) { - formData.append( - "metadata", - new Blob([JSON.stringify(body["metadata"])], { + if (isReactNative) { + formData.append("metadata", { + string: JSON.stringify(body["metadata"]), type: "application/json", - }), - "", - ); + name: "", + } as unknown as Blob); + } else { + formData.append( + "metadata", + new Blob([JSON.stringify(body["metadata"])], { + type: "application/json", + }), + "", + ); + } } if (body["file"] !== undefined) { formData.append("file", body["file"]); diff --git a/tools/codegen/processor/testdata/methods_ref.yaml.ts b/tools/codegen/processor/testdata/methods_ref.yaml.ts index 833c3d536..88309efe4 100644 --- a/tools/codegen/processor/testdata/methods_ref.yaml.ts +++ b/tools/codegen/processor/testdata/methods_ref.yaml.ts @@ -767,16 +767,30 @@ export const createAPIClient = ( ): Promise> => { const url = `${ baseURL }/files/`; const formData = new FormData(); + const isReactNative = + typeof navigator !== "undefined" && + (navigator as { product?: string }).product === "ReactNative"; if (body["bucket-id"] !== undefined) { formData.append("bucket-id", body["bucket-id"]); } if (body["metadata[]"] !== undefined) { body["metadata[]"].forEach((value) => { - formData.append( + if (isReactNative) { + formData.append( + "metadata[]", + { + string: JSON.stringify(value), + type: "application/json", + name: "", + } as unknown as Blob, + ); + } else { + formData.append( "metadata[]", - new Blob([JSON.stringify(value)], { type: "application/json" }), - "", - ) + new Blob([JSON.stringify(value)], { type: "application/json" }), + "", + ); + } } ); } @@ -912,12 +926,26 @@ export const createAPIClient = ( ): Promise> => { const url = `${ baseURL }/files/${id}`; const formData = new FormData(); + const isReactNative = + typeof navigator !== "undefined" && + (navigator as { product?: string }).product === "ReactNative"; if (body["metadata"] !== undefined) { - formData.append( + if (isReactNative) { + formData.append( + "metadata", + { + string: JSON.stringify(body["metadata"]), + type: "application/json", + name: "", + } as unknown as Blob, + ); + } else { + formData.append( "metadata", - new Blob([JSON.stringify(body["metadata"])], { type: "application/json" }), - "", - ); + new Blob([JSON.stringify(body["metadata"])], { type: "application/json" }), + "", + ); + } } if (body["file"] !== undefined) { formData.append("file", body["file"]); diff --git a/tools/codegen/processor/typescript/templates/client.tmpl b/tools/codegen/processor/typescript/templates/client.tmpl index e73fa1c02..fd4a222a6 100644 --- a/tools/codegen/processor/typescript/templates/client.tmpl +++ b/tools/codegen/processor/typescript/templates/client.tmpl @@ -126,6 +126,9 @@ export const createAPIClient = ( }); {{- else if .RequestFormData }} const formData = new FormData(); + const isReactNative = + typeof navigator !== "undefined" && + (navigator as { product?: string }).product === "ReactNative"; {{- range .RequestFormData.Properties }} {{- if eq .Type.Kind "scalar" }} @@ -138,11 +141,22 @@ export const createAPIClient = ( {{- if eq .Type.Item.Kind "scalar" }} formData.append("{{ .Name }}", value) {{- else if eq .Type.Item.Kind "object" }} - formData.append( + if (isReactNative) { + formData.append( + "{{ .Name }}", + { + string: JSON.stringify(value), + type: "application/json", + name: "", + } as unknown as Blob, + ); + } else { + formData.append( "{{ .Name }}", - new Blob([JSON.stringify(value)], { type: "application/json" }), - "", - ) + new Blob([JSON.stringify(value)], { type: "application/json" }), + "", + ); + } {{- else }} TODO {{ .Type.Kind }} {{ .Type.Schema.Schema.Type }} {{- end }} @@ -151,11 +165,22 @@ export const createAPIClient = ( } {{- else if eq .Type.Kind "object" }} if (body["{{ .Name }}"] !== undefined) { - formData.append( + if (isReactNative) { + formData.append( + "{{ .Name }}", + { + string: JSON.stringify(body["{{ .Name }}"]), + type: "application/json", + name: "", + } as unknown as Blob, + ); + } else { + formData.append( "{{ .Name }}", - new Blob([JSON.stringify(body["{{ .Name }}"])], { type: "application/json" }), - "", - ); + new Blob([JSON.stringify(body["{{ .Name }}"])], { type: "application/json" }), + "", + ); + } } {{- else }} TODO {{ .Type.Kind }} {{ .Type.Schema.Schema.Type }}