From 3b5165dbb55f618f15d403e195d4b036b28fbfe1 Mon Sep 17 00:00:00 2001 From: KATT Date: Fri, 29 Mar 2024 11:27:41 +0100 Subject: [PATCH 01/18] hacky --- app/_createUser.tsx | 4 + app/layout.tsx | 37 +++++++--- app/page.tsx | 26 +++++-- package.json | 5 +- pnpm-lock.yaml | 85 ++++++++++++---------- use-action/browser.ts | 3 + use-action/internals/UseActionProvider.tsx | 57 +++++++++++++++ use-action/internals/requestStorage.ts | 26 +++++++ use-action/package.json | 10 +++ use-action/server.ts | 5 ++ use-action/types.ts | 2 + 11 files changed, 201 insertions(+), 59 deletions(-) create mode 100644 use-action/browser.ts create mode 100644 use-action/internals/UseActionProvider.tsx create mode 100644 use-action/internals/requestStorage.ts create mode 100644 use-action/package.json create mode 100644 use-action/server.ts create mode 100644 use-action/types.ts diff --git a/app/_createUser.tsx b/app/_createUser.tsx index ccbd061..bd50a23 100644 --- a/app/_createUser.tsx +++ b/app/_createUser.tsx @@ -1,6 +1,8 @@ "use server"; import { redirect } from "next/navigation"; +import { getRequestStorage } from "use-action"; +import { storeFormData } from "../use-action/internals/requestStorage"; export type CreateUserState = { errors?: { @@ -21,6 +23,8 @@ export async function createUser( // wait 300ms await new Promise((resolve) => setTimeout(resolve, 300)); + storeFormData(payload); + const values = Object.fromEntries(payload); const errors: CreateUserState["errors"] = {}; diff --git a/app/layout.tsx b/app/layout.tsx index d1d7a61..7449b30 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,7 +1,8 @@ import type { Metadata } from "next"; import { Inter } from "next/font/google"; -import "./globals.css"; import { Toaster } from "react-hot-toast"; +import { UseActionProvider, getRequestStorage } from "use-action"; +import "./globals.css"; const inter = Inter({ subsets: ["latin"] }); @@ -10,18 +11,30 @@ export const metadata: Metadata = { description: "Generated by create next app", }; -export default function RootLayout({ - children, -}: Readonly<{ - children: React.ReactNode; -}>) { +export default function RootLayout( + props: Readonly<{ + children: React.ReactNode; + }>, +) { + const formData = getRequestStorage().formData; + return ( - - - {children} + + + + {props.children} - - - + + + + ); } diff --git a/app/page.tsx b/app/page.tsx index 8b62777..8d038de 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -4,7 +4,9 @@ import { useFormState } from "react-dom"; import { createUser } from "./_createUser"; import { useEffect } from "react"; import toast from "react-hot-toast"; +import { ENV, useAction } from "use-action"; +console.log({ ENV }); /** * When JavaScript is available, this component will render a toast. * When JavaScript is not available, this component will render a box. @@ -24,17 +26,25 @@ function ErrorToastOrBox(props: { children: JSX.Element }) { ); } function CreateUserForm() { - const [state, action] = useFormState( - createUser, - // 😷 How come I have to define a default state? Default input/payload would make sense for setting default values - {}, - ); + const [action, payload, state] = useAction(createUser); + console.log({ action, payload, state }); + // if (typeof document === "undefined") { + // const storage = import("../use-action/internals/requestStorage") + // .then((it) => { + // console.log({ it }); + // return it.getRequestStorage(); + // }) + // .then((it) => { + // console.log({ it }); + // }) + // .catch(console.error); + // } return ( // 😷 `
` makes the form work differently with or without JS enabled (inputs should clear) {/* 😷 State is serialized as a hidden input here -- unnecessary payload, `` */} - {state.errors && ( + {state?.errors && ( <>Errors: {JSON.stringify(state.errors, null, 2)} @@ -51,8 +61,8 @@ function CreateUserForm() { id="username" name="username" placeholder="john" - // 😷 how come I have to return this from the backend / server action? It should be readily available in both places - defaultValue={state.input?.username} + // 😷 how come I have to return this from the backend / server action? It should be readily available in both places + defaultValue={payload?.get("username") as string} className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" required /> diff --git a/package.json b/package.json index 5a72e61..cce1936 100644 --- a/package.json +++ b/package.json @@ -9,11 +9,12 @@ "lint": "next lint" }, "dependencies": { - "next": "14.1.4", + "next": "canary", "react": "^18.2.0", "react-dom": "^18.2.0", "react-hot-toast": "^2.4.1", - "zod": "^3.22.4" + "zod": "^3.22.4", + "use-action": "link:./use-action" }, "devDependencies": { "@types/node": "^20.11.30", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 67ec4e9..4def66d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,8 +6,8 @@ settings: dependencies: next: - specifier: 14.1.4 - version: 14.1.4(react-dom@18.2.0)(react@18.2.0) + specifier: canary + version: 14.2.0-canary.43(react-dom@18.2.0)(react@18.2.0) react: specifier: ^18.2.0 version: 18.2.0 @@ -17,6 +17,9 @@ dependencies: react-hot-toast: specifier: ^2.4.1 version: 2.4.1(csstype@3.1.3)(react-dom@18.2.0)(react@18.2.0) + use-action: + specifier: link:./use-action + version: link:use-action zod: specifier: ^3.22.4 version: 3.22.4 @@ -171,8 +174,8 @@ packages: '@jridgewell/sourcemap-codec': 1.4.15 dev: true - /@next/env@14.1.4: - resolution: {integrity: sha512-e7X7bbn3Z6DWnDi75UWn+REgAbLEqxI8Tq2pkFOFAMpWAWApz/YCUhtWMWn410h8Q2fYiYL7Yg5OlxMOCfFjJQ==} + /@next/env@14.2.0-canary.43: + resolution: {integrity: sha512-jBjfC5J053shwv+g4kplFG+iH1TqWwMtLCIpDSplOmRDLdGeai6s3oKmWIxd+MbG5ETSZOl1vCN5A3nMgGkXfg==} dev: false /@next/eslint-plugin-next@14.1.4: @@ -181,8 +184,8 @@ packages: glob: 10.3.10 dev: true - /@next/swc-darwin-arm64@14.1.4: - resolution: {integrity: sha512-ubmUkbmW65nIAOmoxT1IROZdmmJMmdYvXIe8211send9ZYJu+SqxSnJM4TrPj9wmL6g9Atvj0S/2cFmMSS99jg==} + /@next/swc-darwin-arm64@14.2.0-canary.43: + resolution: {integrity: sha512-M9Asj8J6GMVNdMRnDnR+hELiyjgaHSUYAZz4M7ro5Vd1X8wpg3jygd/RnkTv+hhHn3rqwV9jWyZ4xdyG3SORrg==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] @@ -190,8 +193,8 @@ packages: dev: false optional: true - /@next/swc-darwin-x64@14.1.4: - resolution: {integrity: sha512-b0Xo1ELj3u7IkZWAKcJPJEhBop117U78l70nfoQGo4xUSvv0PJSTaV4U9xQBLvZlnjsYkc8RwQN1HoH/oQmLlQ==} + /@next/swc-darwin-x64@14.2.0-canary.43: + resolution: {integrity: sha512-3BQ5FirbYZgXAFOCUynDr/Sl0fcFfEiLiDVdGMaJO7754fuWJShcj5tODiFC2B7MgLsVkri/84prBzsjkg76jA==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] @@ -199,8 +202,8 @@ packages: dev: false optional: true - /@next/swc-linux-arm64-gnu@14.1.4: - resolution: {integrity: sha512-457G0hcLrdYA/u1O2XkRMsDKId5VKe3uKPvrKVOyuARa6nXrdhJOOYU9hkKKyQTMru1B8qEP78IAhf/1XnVqKA==} + /@next/swc-linux-arm64-gnu@14.2.0-canary.43: + resolution: {integrity: sha512-VoCLYDTD2bkLsUkT0bACplrdpTw+IBKdFr5ih85atePrujCz6dMPUxeNMwH9aYL7r3PgzH6dR30r0Y5TFwUUSg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] @@ -208,8 +211,8 @@ packages: dev: false optional: true - /@next/swc-linux-arm64-musl@14.1.4: - resolution: {integrity: sha512-l/kMG+z6MB+fKA9KdtyprkTQ1ihlJcBh66cf0HvqGP+rXBbOXX0dpJatjZbHeunvEHoBBS69GYQG5ry78JMy3g==} + /@next/swc-linux-arm64-musl@14.2.0-canary.43: + resolution: {integrity: sha512-8c35oylAS4Ggu155txTpOv7VG4BzG8BTluVbUZuaneZwsZi6VTbjVKMVnLYmmdcdRkkvRgPc83oUr2HGxwxFBw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] @@ -217,8 +220,8 @@ packages: dev: false optional: true - /@next/swc-linux-x64-gnu@14.1.4: - resolution: {integrity: sha512-BapIFZ3ZRnvQ1uWbmqEGJuPT9cgLwvKtxhK/L2t4QYO7l+/DxXuIGjvp1x8rvfa/x1FFSsipERZK70pewbtJtw==} + /@next/swc-linux-x64-gnu@14.2.0-canary.43: + resolution: {integrity: sha512-PHy7clJ+ChZzNJ3c9A2IrWJN4aNa+FZ+v39XNdcjdkdhPvwu1QSvtirWSbxqKpAqgA/3sMhAGCvwOx6yeBs4Ug==} engines: {node: '>= 10'} cpu: [x64] os: [linux] @@ -226,8 +229,8 @@ packages: dev: false optional: true - /@next/swc-linux-x64-musl@14.1.4: - resolution: {integrity: sha512-mqVxTwk4XuBl49qn2A5UmzFImoL1iLm0KQQwtdRJRKl21ylQwwGCxJtIYo2rbfkZHoSKlh/YgztY0qH3wG1xIg==} + /@next/swc-linux-x64-musl@14.2.0-canary.43: + resolution: {integrity: sha512-pvma+GKwkDEzhQRrwl9P4oGu9A9NGJH/Za+SG/XwWph2i78+4OMDCKrmKEJ1T5BE6Bgo+Emfhdy8TmfqHPQQCg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] @@ -235,8 +238,8 @@ packages: dev: false optional: true - /@next/swc-win32-arm64-msvc@14.1.4: - resolution: {integrity: sha512-xzxF4ErcumXjO2Pvg/wVGrtr9QQJLk3IyQX1ddAC/fi6/5jZCZ9xpuL9Tzc4KPWMFq8GGWFVDMshZOdHGdkvag==} + /@next/swc-win32-arm64-msvc@14.2.0-canary.43: + resolution: {integrity: sha512-b1npBheIu7/BgMZTCFkuNYv0Q/N9u6+7MYY5xjZDVIutW8ut2V93JZqeC2SYWFm03I+LNdYjplRhn3TVerz9Xg==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] @@ -244,8 +247,8 @@ packages: dev: false optional: true - /@next/swc-win32-ia32-msvc@14.1.4: - resolution: {integrity: sha512-WZiz8OdbkpRw6/IU/lredZWKKZopUMhcI2F+XiMAcPja0uZYdMTZQRoQ0WZcvinn9xZAidimE7tN9W5v9Yyfyw==} + /@next/swc-win32-ia32-msvc@14.2.0-canary.43: + resolution: {integrity: sha512-1bZDCGyQzvdRNxVUUhsjBZOzBEEoQlh1r91ifjUz9nhcFYOlmP6IplPMjaLmG+GJMUiI36j5svdPYO3LP08b8g==} engines: {node: '>= 10'} cpu: [ia32] os: [win32] @@ -253,8 +256,8 @@ packages: dev: false optional: true - /@next/swc-win32-x64-msvc@14.1.4: - resolution: {integrity: sha512-4Rto21sPfw555sZ/XNLqfxDUNeLhNYGO2dlPqsnuCg8N8a2a9u1ltqBOPQ4vj1Gf7eJC0W2hHG2eYUHuiXgY2w==} + /@next/swc-win32-x64-msvc@14.2.0-canary.43: + resolution: {integrity: sha512-pU9gjLmp4yjYzBqCGa5bQ0iyJ5D73IRITEUFKrjZPi0XHUbFLrhcaaCsnVgMO4xfOQJgS7ODuQB7N0iPk7/EMw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -294,9 +297,14 @@ packages: resolution: {integrity: sha512-0HejFckBN2W+ucM6cUOlwsByTKt9/+0tWhqUffNIcHqCXkthY/mZ7AuYPK/2IIaGWhdl0h+tICDO0ssLMd6XMQ==} dev: true - /@swc/helpers@0.5.2: - resolution: {integrity: sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw==} + /@swc/counter@0.1.3: + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + dev: false + + /@swc/helpers@0.5.5: + resolution: {integrity: sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==} dependencies: + '@swc/counter': 0.1.3 tslib: 2.6.2 dev: false @@ -1954,23 +1962,26 @@ packages: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} dev: true - /next@14.1.4(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-1WTaXeSrUwlz/XcnhGTY7+8eiaFvdet5z9u3V2jb+Ek1vFo0VhHKSAIJvDWfQpttWjnyw14kBeq28TPq7bTeEQ==} + /next@14.2.0-canary.43(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-tL5fxsleOuRS7Momx5wRwkCOPLybQKwgJnpzgMGVReQs+kA9lkQiBANvlYdAsrvZ3vjzx2H+9mSqKDcKaC8UXQ==} engines: {node: '>=18.17.0'} hasBin: true peerDependencies: '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.41.2 react: ^18.2.0 react-dom: ^18.2.0 sass: ^1.3.0 peerDependenciesMeta: '@opentelemetry/api': optional: true + '@playwright/test': + optional: true sass: optional: true dependencies: - '@next/env': 14.1.4 - '@swc/helpers': 0.5.2 + '@next/env': 14.2.0-canary.43 + '@swc/helpers': 0.5.5 busboy: 1.6.0 caniuse-lite: 1.0.30001600 graceful-fs: 4.2.11 @@ -1979,15 +1990,15 @@ packages: react-dom: 18.2.0(react@18.2.0) styled-jsx: 5.1.1(react@18.2.0) optionalDependencies: - '@next/swc-darwin-arm64': 14.1.4 - '@next/swc-darwin-x64': 14.1.4 - '@next/swc-linux-arm64-gnu': 14.1.4 - '@next/swc-linux-arm64-musl': 14.1.4 - '@next/swc-linux-x64-gnu': 14.1.4 - '@next/swc-linux-x64-musl': 14.1.4 - '@next/swc-win32-arm64-msvc': 14.1.4 - '@next/swc-win32-ia32-msvc': 14.1.4 - '@next/swc-win32-x64-msvc': 14.1.4 + '@next/swc-darwin-arm64': 14.2.0-canary.43 + '@next/swc-darwin-x64': 14.2.0-canary.43 + '@next/swc-linux-arm64-gnu': 14.2.0-canary.43 + '@next/swc-linux-arm64-musl': 14.2.0-canary.43 + '@next/swc-linux-x64-gnu': 14.2.0-canary.43 + '@next/swc-linux-x64-musl': 14.2.0-canary.43 + '@next/swc-win32-arm64-msvc': 14.2.0-canary.43 + '@next/swc-win32-ia32-msvc': 14.2.0-canary.43 + '@next/swc-win32-x64-msvc': 14.2.0-canary.43 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros diff --git a/use-action/browser.ts b/use-action/browser.ts new file mode 100644 index 0000000..e041fde --- /dev/null +++ b/use-action/browser.ts @@ -0,0 +1,3 @@ +export const ENV = "browser" as string; + +export * from "./internals/UseActionProvider"; diff --git a/use-action/internals/UseActionProvider.tsx b/use-action/internals/UseActionProvider.tsx new file mode 100644 index 0000000..a12d303 --- /dev/null +++ b/use-action/internals/UseActionProvider.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { createContext, use } from "react"; +import { useFormState } from "react-dom"; + +type Value = null | { + formData: FormData; +}; +const UseActionContext = createContext(null); + +export function UseActionProvider(props: { + children: React.ReactNode; + value: Value; +}) { + console.log( + "Provider", + props.value, + "FormData?", + props.value?.formData instanceof FormData, + ); + return ( + + {props.children} + + ); +} + +export function useAction( + action: (state: Awaited, payload: Payload) => State, + permalink?: string, +): [ + dispatch: (payload: Payload) => State, + // "input" + payload: null | Payload, + // "output" + state: Awaited | null, + // pending: boolean; +] { + const ctx = use(UseActionContext); + const [state, dispatch] = useFormState( + action as any, + null as any, + permalink, + ); + + let payload = (ctx?.formData ?? null) as Payload | null; + if (payload && !(payload instanceof FormData)) { + const fd = new FormData(); + + for (const [key, value] of payload as any[]) { + (fd as FormData).append(key, value); + } + payload = fd as any; + } + + return [dispatch as any, payload, state]; +} diff --git a/use-action/internals/requestStorage.ts b/use-action/internals/requestStorage.ts new file mode 100644 index 0000000..d757baf --- /dev/null +++ b/use-action/internals/requestStorage.ts @@ -0,0 +1,26 @@ +import { + getExpectedRequestStore, + RequestStore, +} from "next/dist/client/components/request-async-storage.external"; + +interface RequestStoreWithFormData extends RequestStore { + formData?: FormData; +} +export function getRequestStorage() { + const storage = getExpectedRequestStore("getRequestStorage"); + + return storage as RequestStoreWithFormData; +} + +export function storeFormData(formData: FormData) { + // create copy + const copy = new FormData(); + for (const [key, value] of formData as any) { + if (typeof value === "string") { + // omit e.g. File from new FormData + copy.append(key, value); + } + } + const storage = getRequestStorage(); + storage.formData = copy; +} diff --git a/use-action/package.json b/use-action/package.json new file mode 100644 index 0000000..addd1c1 --- /dev/null +++ b/use-action/package.json @@ -0,0 +1,10 @@ +{ + "name": "use-action", + "exports": { + ".": { + "types": "./types.ts", + "browser": "./browser.ts", + "default": "./server.ts" + } + } +} diff --git a/use-action/server.ts b/use-action/server.ts new file mode 100644 index 0000000..ffa33a6 --- /dev/null +++ b/use-action/server.ts @@ -0,0 +1,5 @@ +export const ENV = "server" as string; + +export { getRequestStorage } from "./internals/requestStorage"; + +export * from "./internals/UseActionProvider"; diff --git a/use-action/types.ts b/use-action/types.ts new file mode 100644 index 0000000..47d6c70 --- /dev/null +++ b/use-action/types.ts @@ -0,0 +1,2 @@ +export * from "./server"; +export {} from "./browser"; From 80e90dc3c7b1af0b17895bf5d570694275116b9f Mon Sep 17 00:00:00 2001 From: KATT Date: Sun, 31 Mar 2024 18:58:45 +0200 Subject: [PATCH 02/18] wip --- app/_createUser.tsx | 41 +++-------- app/layout.tsx | 14 +--- app/page.tsx | 23 ++----- package.json | 6 +- pnpm-lock.yaml | 68 +++++++++---------- use-action/browser.ts | 3 +- use-action/context/browser.tsx | 17 +++++ use-action/context/server.tsx | 22 ++++++ .../shared.tsx} | 41 ++++------- use-action/internals/requestStorage.ts | 26 ------- use-action/lib/betterRedirect.ts | 10 +++ use-action/lib/createAction.ts | 20 ++++++ use-action/lib/requestStorage.ts | 30 ++++++++ use-action/server.ts | 7 +- 14 files changed, 174 insertions(+), 154 deletions(-) create mode 100644 use-action/context/browser.tsx create mode 100644 use-action/context/server.tsx rename use-action/{internals/UseActionProvider.tsx => context/shared.tsx} (58%) delete mode 100644 use-action/internals/requestStorage.ts create mode 100644 use-action/lib/betterRedirect.ts create mode 100644 use-action/lib/createAction.ts create mode 100644 use-action/lib/requestStorage.ts diff --git a/app/_createUser.tsx b/app/_createUser.tsx index bd50a23..6a60c86 100644 --- a/app/_createUser.tsx +++ b/app/_createUser.tsx @@ -1,55 +1,32 @@ "use server"; -import { redirect } from "next/navigation"; -import { getRequestStorage } from "use-action"; -import { storeFormData } from "../use-action/internals/requestStorage"; - -export type CreateUserState = { - errors?: { - username?: string; - }; - input?: { - username?: string; - }; +import { betterRedirect, createAction } from "use-action"; + +export type ValidationErrors = { + username?: string; }; const usernames = ["john", "jane"]; -export async function createUser( - _: CreateUserState, - payload: FormData, -): Promise { +export const createUser = createAction()(async (payload) => { console.log("Creating user with payload", payload); // wait 300ms await new Promise((resolve) => setTimeout(resolve, 300)); - storeFormData(payload); - const values = Object.fromEntries(payload); - const errors: CreateUserState["errors"] = {}; + const errors: ValidationErrors = {}; if (usernames.includes(values.username as string)) { errors.username = `Username '${values.username}' is already taken`; } - if (Object.keys(errors).length > 0) { - // 😷 Some issues: - // What I want to do here is to return the errors and the payload so that the form can be re-rendered with the errors and inputs. - // return { errors, payload }; - // 👆❌ This doesn't work because: - // - File is not serializable (makes sense? it should be automatically omitted?) - // - FormData is not serializable (Next.js issue?) + if (Object.keys(errors).length > 0) { return { errors, - // 😷 Why do I even need to return this? - // 😷 I have to **pick** values. I can't use `Object.fromEntries(formData)` either since that includes a bunch of junk of React-internals - input: { - username: values.username as string, - }, }; } usernames.push(payload.get("username") as string); - redirect("/success"); -} + betterRedirect("/success"); +}); diff --git a/app/layout.tsx b/app/layout.tsx index 7449b30..09da45a 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,7 +1,7 @@ import type { Metadata } from "next"; import { Inter } from "next/font/google"; import { Toaster } from "react-hot-toast"; -import { UseActionProvider, getRequestStorage } from "use-action"; +import { UseActionProvider } from "use-action"; import "./globals.css"; const inter = Inter({ subsets: ["latin"] }); @@ -16,18 +16,8 @@ export default function RootLayout( children: React.ReactNode; }>, ) { - const formData = getRequestStorage().formData; - return ( - + {props.children} diff --git a/app/page.tsx b/app/page.tsx index 8d038de..c9fd81e 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,10 +1,9 @@ "use client"; -import { useFormState } from "react-dom"; -import { createUser } from "./_createUser"; import { useEffect } from "react"; import toast from "react-hot-toast"; import { ENV, useAction } from "use-action"; +import { createUser } from "./_createUser"; console.log({ ENV }); /** @@ -26,27 +25,15 @@ function ErrorToastOrBox(props: { children: JSX.Element }) { ); } function CreateUserForm() { - const [action, payload, state] = useAction(createUser); - console.log({ action, payload, state }); + const [action, input, output] = useAction(createUser); - // if (typeof document === "undefined") { - // const storage = import("../use-action/internals/requestStorage") - // .then((it) => { - // console.log({ it }); - // return it.getRequestStorage(); - // }) - // .then((it) => { - // console.log({ it }); - // }) - // .catch(console.error); - // } return ( // 😷 `` makes the form work differently with or without JS enabled (inputs should clear) {/* 😷 State is serialized as a hidden input here -- unnecessary payload, `` */} - {state?.errors && ( + {output?.errors && ( - <>Errors: {JSON.stringify(state.errors, null, 2)} + <>Errors: {JSON.stringify(output.errors, null, 2)} )}
@@ -62,7 +49,7 @@ function CreateUserForm() { name="username" placeholder="john" // 😷 how come I have to return this from the backend / server action? It should be readily available in both places - defaultValue={payload?.get("username") as string} + defaultValue={input?.get("username") as string} className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" required /> diff --git a/package.json b/package.json index cce1936..9ad50b4 100644 --- a/package.json +++ b/package.json @@ -9,12 +9,12 @@ "lint": "next lint" }, "dependencies": { - "next": "canary", + "next": "14.2.0-canary.50", "react": "^18.2.0", "react-dom": "^18.2.0", "react-hot-toast": "^2.4.1", - "zod": "^3.22.4", - "use-action": "link:./use-action" + "use-action": "link:./use-action", + "zod": "^3.22.4" }, "devDependencies": { "@types/node": "^20.11.30", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4def66d..cb4e603 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,8 +6,8 @@ settings: dependencies: next: - specifier: canary - version: 14.2.0-canary.43(react-dom@18.2.0)(react@18.2.0) + specifier: 14.2.0-canary.50 + version: 14.2.0-canary.50(react-dom@18.2.0)(react@18.2.0) react: specifier: ^18.2.0 version: 18.2.0 @@ -174,8 +174,8 @@ packages: '@jridgewell/sourcemap-codec': 1.4.15 dev: true - /@next/env@14.2.0-canary.43: - resolution: {integrity: sha512-jBjfC5J053shwv+g4kplFG+iH1TqWwMtLCIpDSplOmRDLdGeai6s3oKmWIxd+MbG5ETSZOl1vCN5A3nMgGkXfg==} + /@next/env@14.2.0-canary.50: + resolution: {integrity: sha512-COLktqbQGmSANtTTKVs4heykkT4YSLM+GU1CbHKpSXnyEP98yrWcfMTMeTwcEZCgilvI1gPT5zVO/ISU1o/X5A==} dev: false /@next/eslint-plugin-next@14.1.4: @@ -184,8 +184,8 @@ packages: glob: 10.3.10 dev: true - /@next/swc-darwin-arm64@14.2.0-canary.43: - resolution: {integrity: sha512-M9Asj8J6GMVNdMRnDnR+hELiyjgaHSUYAZz4M7ro5Vd1X8wpg3jygd/RnkTv+hhHn3rqwV9jWyZ4xdyG3SORrg==} + /@next/swc-darwin-arm64@14.2.0-canary.50: + resolution: {integrity: sha512-el2drGIjRNuLqqahuCoKou50pEqacrcGvhOphiU8wQPWOku3d762sN9pTyunyLVshjLNOI/gDxh1Ja2dDcmXzg==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] @@ -193,8 +193,8 @@ packages: dev: false optional: true - /@next/swc-darwin-x64@14.2.0-canary.43: - resolution: {integrity: sha512-3BQ5FirbYZgXAFOCUynDr/Sl0fcFfEiLiDVdGMaJO7754fuWJShcj5tODiFC2B7MgLsVkri/84prBzsjkg76jA==} + /@next/swc-darwin-x64@14.2.0-canary.50: + resolution: {integrity: sha512-gulXuO14RZODSB3hU+Rb+CHWymH7kGAcvsP7SA95wUXx2CAuugFfI90sHL4ieQib5N058DoIQPvYILiqP9RpxQ==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] @@ -202,8 +202,8 @@ packages: dev: false optional: true - /@next/swc-linux-arm64-gnu@14.2.0-canary.43: - resolution: {integrity: sha512-VoCLYDTD2bkLsUkT0bACplrdpTw+IBKdFr5ih85atePrujCz6dMPUxeNMwH9aYL7r3PgzH6dR30r0Y5TFwUUSg==} + /@next/swc-linux-arm64-gnu@14.2.0-canary.50: + resolution: {integrity: sha512-6ZXM32VGQU1liB9+r3AHIsUZBbBQGMBpWdGnRzvCQ+AUVhL01x2P77AXCjhxeLUgrUisl3Cw+UDc+WvuLcygjQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] @@ -211,8 +211,8 @@ packages: dev: false optional: true - /@next/swc-linux-arm64-musl@14.2.0-canary.43: - resolution: {integrity: sha512-8c35oylAS4Ggu155txTpOv7VG4BzG8BTluVbUZuaneZwsZi6VTbjVKMVnLYmmdcdRkkvRgPc83oUr2HGxwxFBw==} + /@next/swc-linux-arm64-musl@14.2.0-canary.50: + resolution: {integrity: sha512-Is7FNrgY1ifBMKs9Y7fx6OJp7OjwfMMl8BhlN+UzbkMtZF9R45qLnRSWOu0gkQLqfqR0wx//Bmkr/d25qqZxjg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] @@ -220,8 +220,8 @@ packages: dev: false optional: true - /@next/swc-linux-x64-gnu@14.2.0-canary.43: - resolution: {integrity: sha512-PHy7clJ+ChZzNJ3c9A2IrWJN4aNa+FZ+v39XNdcjdkdhPvwu1QSvtirWSbxqKpAqgA/3sMhAGCvwOx6yeBs4Ug==} + /@next/swc-linux-x64-gnu@14.2.0-canary.50: + resolution: {integrity: sha512-rKcciKNtCVrcj9zZ+JBK1AgIbeISHZz2OcTa/i1O3l+VwNDN25YAPaVDL0aPX6e9N0SR5W33b+bSQMHOc1FGhA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] @@ -229,8 +229,8 @@ packages: dev: false optional: true - /@next/swc-linux-x64-musl@14.2.0-canary.43: - resolution: {integrity: sha512-pvma+GKwkDEzhQRrwl9P4oGu9A9NGJH/Za+SG/XwWph2i78+4OMDCKrmKEJ1T5BE6Bgo+Emfhdy8TmfqHPQQCg==} + /@next/swc-linux-x64-musl@14.2.0-canary.50: + resolution: {integrity: sha512-tVFgS5lOa/h6h5//4p9mhcV7XThMAzMJQoC+j7y+yhnGnb17t4pQPR3FXAonncgm9OyCkA2N0O0hqwsnj6oCLA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] @@ -238,8 +238,8 @@ packages: dev: false optional: true - /@next/swc-win32-arm64-msvc@14.2.0-canary.43: - resolution: {integrity: sha512-b1npBheIu7/BgMZTCFkuNYv0Q/N9u6+7MYY5xjZDVIutW8ut2V93JZqeC2SYWFm03I+LNdYjplRhn3TVerz9Xg==} + /@next/swc-win32-arm64-msvc@14.2.0-canary.50: + resolution: {integrity: sha512-1FmGWELLW7XdrNmJQca7vbBUIVOd84LGZQCO6gRIvmAw3Oh7S3UwP2rAsZ9K24Ox44TnK2xV4C4t9BV8PnHzMQ==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] @@ -247,8 +247,8 @@ packages: dev: false optional: true - /@next/swc-win32-ia32-msvc@14.2.0-canary.43: - resolution: {integrity: sha512-1bZDCGyQzvdRNxVUUhsjBZOzBEEoQlh1r91ifjUz9nhcFYOlmP6IplPMjaLmG+GJMUiI36j5svdPYO3LP08b8g==} + /@next/swc-win32-ia32-msvc@14.2.0-canary.50: + resolution: {integrity: sha512-GmIQ0VdGEExzZSh00wCjAILfdqR4dzSFnnXvjAnNBehS8uadHhlLY7fpsVOtNh7byd5gxgbt+dFz7Y4GrrCRbA==} engines: {node: '>= 10'} cpu: [ia32] os: [win32] @@ -256,8 +256,8 @@ packages: dev: false optional: true - /@next/swc-win32-x64-msvc@14.2.0-canary.43: - resolution: {integrity: sha512-pU9gjLmp4yjYzBqCGa5bQ0iyJ5D73IRITEUFKrjZPi0XHUbFLrhcaaCsnVgMO4xfOQJgS7ODuQB7N0iPk7/EMw==} + /@next/swc-win32-x64-msvc@14.2.0-canary.50: + resolution: {integrity: sha512-kNqwVNRCoujVBe2C4YdtwfrF8103nMmsV3B/IvMnxB3pAotvYLzUTboflT2Wx5AMFTNY1KGYY8GGFjotX5/GRQ==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -1962,8 +1962,8 @@ packages: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} dev: true - /next@14.2.0-canary.43(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-tL5fxsleOuRS7Momx5wRwkCOPLybQKwgJnpzgMGVReQs+kA9lkQiBANvlYdAsrvZ3vjzx2H+9mSqKDcKaC8UXQ==} + /next@14.2.0-canary.50(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-7uNL5MrCx7YXJO1B/H3619HkLQhlXdAWIsgMHzetrz7ffE3isZoy6u5aXkkITfyKBfbvMbyhUcd2MH7HCdivfg==} engines: {node: '>=18.17.0'} hasBin: true peerDependencies: @@ -1980,7 +1980,7 @@ packages: sass: optional: true dependencies: - '@next/env': 14.2.0-canary.43 + '@next/env': 14.2.0-canary.50 '@swc/helpers': 0.5.5 busboy: 1.6.0 caniuse-lite: 1.0.30001600 @@ -1990,15 +1990,15 @@ packages: react-dom: 18.2.0(react@18.2.0) styled-jsx: 5.1.1(react@18.2.0) optionalDependencies: - '@next/swc-darwin-arm64': 14.2.0-canary.43 - '@next/swc-darwin-x64': 14.2.0-canary.43 - '@next/swc-linux-arm64-gnu': 14.2.0-canary.43 - '@next/swc-linux-arm64-musl': 14.2.0-canary.43 - '@next/swc-linux-x64-gnu': 14.2.0-canary.43 - '@next/swc-linux-x64-musl': 14.2.0-canary.43 - '@next/swc-win32-arm64-msvc': 14.2.0-canary.43 - '@next/swc-win32-ia32-msvc': 14.2.0-canary.43 - '@next/swc-win32-x64-msvc': 14.2.0-canary.43 + '@next/swc-darwin-arm64': 14.2.0-canary.50 + '@next/swc-darwin-x64': 14.2.0-canary.50 + '@next/swc-linux-arm64-gnu': 14.2.0-canary.50 + '@next/swc-linux-arm64-musl': 14.2.0-canary.50 + '@next/swc-linux-x64-gnu': 14.2.0-canary.50 + '@next/swc-linux-x64-musl': 14.2.0-canary.50 + '@next/swc-win32-arm64-msvc': 14.2.0-canary.50 + '@next/swc-win32-ia32-msvc': 14.2.0-canary.50 + '@next/swc-win32-x64-msvc': 14.2.0-canary.50 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros diff --git a/use-action/browser.ts b/use-action/browser.ts index e041fde..0931494 100644 --- a/use-action/browser.ts +++ b/use-action/browser.ts @@ -1,3 +1,4 @@ export const ENV = "browser" as string; -export * from "./internals/UseActionProvider"; +export * from "./context/browser"; +export { useAction } from "./context/shared"; diff --git a/use-action/context/browser.tsx b/use-action/context/browser.tsx new file mode 100644 index 0000000..43f1b5d --- /dev/null +++ b/use-action/context/browser.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { UseActionContext } from "./shared"; + +export function UseActionProvider(props: { children: React.ReactNode }) { + console.log("i am browser an happy"); + return ( + + {props.children} + + ); +} diff --git a/use-action/context/server.tsx b/use-action/context/server.tsx new file mode 100644 index 0000000..30da277 --- /dev/null +++ b/use-action/context/server.tsx @@ -0,0 +1,22 @@ +"use client"; +import { UseActionContext } from "./shared"; +import { getRequestStorage } from "../lib/requestStorage"; + +console.log("hello i am server"); +export function UseActionProvider(props: { children: React.ReactNode }) { + const storage = getRequestStorage(); + return ( + + {props.children} + + ); +} diff --git a/use-action/internals/UseActionProvider.tsx b/use-action/context/shared.tsx similarity index 58% rename from use-action/internals/UseActionProvider.tsx rename to use-action/context/shared.tsx index a12d303..50b6725 100644 --- a/use-action/internals/UseActionProvider.tsx +++ b/use-action/context/shared.tsx @@ -1,40 +1,29 @@ "use client"; - import { createContext, use } from "react"; import { useFormState } from "react-dom"; -type Value = null | { - formData: FormData; -}; -const UseActionContext = createContext(null); - -export function UseActionProvider(props: { - children: React.ReactNode; - value: Value; -}) { - console.log( - "Provider", - props.value, - "FormData?", - props.value?.formData instanceof FormData, - ); - return ( - - {props.children} - - ); -} +export type Value = [ + payload: unknown, + // TODO add info about the action +]; +export const UseActionContext = createContext(null); export function useAction( action: (state: Awaited, payload: Payload) => State, permalink?: string, ): [ + /** + * The action to use in a `` element. + */ dispatch: (payload: Payload) => State, - // "input" + /** + * Will be `null` if no payload is available. + */ payload: null | Payload, - // "output" + /** + * Will be `null` if no state is available. + */ state: Awaited | null, - // pending: boolean; ] { const ctx = use(UseActionContext); const [state, dispatch] = useFormState( @@ -43,7 +32,7 @@ export function useAction( permalink, ); - let payload = (ctx?.formData ?? null) as Payload | null; + let payload = (ctx?.[0] ?? null) as Payload | null; if (payload && !(payload instanceof FormData)) { const fd = new FormData(); diff --git a/use-action/internals/requestStorage.ts b/use-action/internals/requestStorage.ts deleted file mode 100644 index d757baf..0000000 --- a/use-action/internals/requestStorage.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { - getExpectedRequestStore, - RequestStore, -} from "next/dist/client/components/request-async-storage.external"; - -interface RequestStoreWithFormData extends RequestStore { - formData?: FormData; -} -export function getRequestStorage() { - const storage = getExpectedRequestStore("getRequestStorage"); - - return storage as RequestStoreWithFormData; -} - -export function storeFormData(formData: FormData) { - // create copy - const copy = new FormData(); - for (const [key, value] of formData as any) { - if (typeof value === "string") { - // omit e.g. File from new FormData - copy.append(key, value); - } - } - const storage = getRequestStorage(); - storage.formData = copy; -} diff --git a/use-action/lib/betterRedirect.ts b/use-action/lib/betterRedirect.ts new file mode 100644 index 0000000..01765fc --- /dev/null +++ b/use-action/lib/betterRedirect.ts @@ -0,0 +1,10 @@ +import { redirect } from "next/navigation"; + +/** + * Like Next.js' redirect but better represents what actually happens + * When a call to a server action redirects, it will actually `return` `undefined` + * @see https://github.com/vercel/next.js/issues/63771 + */ +export function betterRedirect(...args: Parameters) { + return redirect(...args) as unknown as undefined; +} diff --git a/use-action/lib/createAction.ts b/use-action/lib/createAction.ts new file mode 100644 index 0000000..3d8be6d --- /dev/null +++ b/use-action/lib/createAction.ts @@ -0,0 +1,20 @@ +import { storeActionPayload } from "./requestStorage"; + +export function createAction() { + return function action(fn: (input: TInput) => Promise) { + return async function wrapper(...args: unknown[]) { + /** + * When you wrap an action with useFormState, it gets an extra argument as its first argument. + * The submitted form data is therefore its second argument instead of its first as it would usually be. + * The new first argument that gets added is the current state of the form. + * @see https://react.dev/reference/react-dom/hooks/useFormState#my-action-can-no-longer-read-the-submitted-form-data + */ + let input = args.length === 1 ? args[0] : args[1]; + + // store action payload so it can be used in server-side components + storeActionPayload(input); + + return await fn(input as TInput); + }; + }; +} diff --git a/use-action/lib/requestStorage.ts b/use-action/lib/requestStorage.ts new file mode 100644 index 0000000..cdeaa37 --- /dev/null +++ b/use-action/lib/requestStorage.ts @@ -0,0 +1,30 @@ +import { + getExpectedRequestStore, + RequestStore, +} from "next/dist/client/components/request-async-storage.external"; + +interface RequestStoreWithActionPayload extends RequestStore { + actionPayload?: unknown; +} +export function getRequestStorage() { + console.log("hello--------------"); + const storage = getExpectedRequestStore("getRequestStorage"); + + return storage as RequestStoreWithActionPayload; +} + +export function storeActionPayload(payload: unknown) { + const storage = getRequestStorage(); + let store = payload; + if (payload instanceof FormData) { + // create copy without File + store = new FormData(); + for (const [key, value] of payload as any) { + if (typeof value === "string") { + // omit e.g. File from new FormData + (store as FormData).append(key, value); + } + } + } + storage.actionPayload = store; +} diff --git a/use-action/server.ts b/use-action/server.ts index ffa33a6..a7e9050 100644 --- a/use-action/server.ts +++ b/use-action/server.ts @@ -1,5 +1,8 @@ export const ENV = "server" as string; -export { getRequestStorage } from "./internals/requestStorage"; +export { getRequestStorage } from "./lib/requestStorage"; -export * from "./internals/UseActionProvider"; +export { createAction } from "./lib/createAction"; +export { betterRedirect } from "./lib/betterRedirect"; +export { useAction } from "./context/shared"; +export { UseActionProvider } from "./context/server"; From c5c3cdadad9e92a337151ed7c852b33caf11a533 Mon Sep 17 00:00:00 2001 From: KATT Date: Sun, 31 Mar 2024 19:08:07 +0200 Subject: [PATCH 03/18] wip more --- app/layout.tsx | 7 +++++-- app/page.tsx | 3 +-- use-action/browser.ts | 9 +++++++-- use-action/context/browser.tsx | 17 ----------------- use-action/context/server.tsx | 26 ++++++++------------------ use-action/context/shared.tsx | 17 +++++++++++++++-- use-action/lib/requestStorage.ts | 1 - use-action/server.ts | 6 ++++-- 8 files changed, 40 insertions(+), 46 deletions(-) delete mode 100644 use-action/context/browser.tsx diff --git a/app/layout.tsx b/app/layout.tsx index 09da45a..68f6712 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,11 +1,13 @@ import type { Metadata } from "next"; import { Inter } from "next/font/google"; import { Toaster } from "react-hot-toast"; -import { UseActionProvider } from "use-action"; +import { ENV, UseActionProvider, getUseActionProviderValue } from "use-action"; import "./globals.css"; const inter = Inter({ subsets: ["latin"] }); +console.log({ ENV }); + export const metadata: Metadata = { title: "Create Next App", description: "Generated by create next app", @@ -16,8 +18,9 @@ export default function RootLayout( children: React.ReactNode; }>, ) { + const value = getUseActionProviderValue(); return ( - + {props.children} diff --git a/app/page.tsx b/app/page.tsx index c9fd81e..3b1a3f6 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -2,10 +2,9 @@ import { useEffect } from "react"; import toast from "react-hot-toast"; -import { ENV, useAction } from "use-action"; +import { useAction } from "use-action"; import { createUser } from "./_createUser"; -console.log({ ENV }); /** * When JavaScript is available, this component will render a toast. * When JavaScript is not available, this component will render a box. diff --git a/use-action/browser.ts b/use-action/browser.ts index 0931494..d2650aa 100644 --- a/use-action/browser.ts +++ b/use-action/browser.ts @@ -1,4 +1,9 @@ +import { UseActionProviderValue } from "./context/shared"; + export const ENV = "browser" as string; -export * from "./context/browser"; -export { useAction } from "./context/shared"; +export { useAction, UseActionProvider } from "./context/shared"; + +export function getUseActionProviderValue(): UseActionProviderValue | null { + return null; +} diff --git a/use-action/context/browser.tsx b/use-action/context/browser.tsx deleted file mode 100644 index 43f1b5d..0000000 --- a/use-action/context/browser.tsx +++ /dev/null @@ -1,17 +0,0 @@ -"use client"; - -import { UseActionContext } from "./shared"; - -export function UseActionProvider(props: { children: React.ReactNode }) { - console.log("i am browser an happy"); - return ( - - {props.children} - - ); -} diff --git a/use-action/context/server.tsx b/use-action/context/server.tsx index 30da277..6951a81 100644 --- a/use-action/context/server.tsx +++ b/use-action/context/server.tsx @@ -1,22 +1,12 @@ -"use client"; -import { UseActionContext } from "./shared"; import { getRequestStorage } from "../lib/requestStorage"; +import { UseActionProviderValue } from "./shared"; -console.log("hello i am server"); -export function UseActionProvider(props: { children: React.ReactNode }) { +export function getUseActionProviderValue(): UseActionProviderValue | null { const storage = getRequestStorage(); - return ( - - {props.children} - - ); + + if (storage.actionPayload) { + return null; + } + + return [storage.actionPayload]; } diff --git a/use-action/context/shared.tsx b/use-action/context/shared.tsx index 50b6725..145700a 100644 --- a/use-action/context/shared.tsx +++ b/use-action/context/shared.tsx @@ -2,11 +2,13 @@ import { createContext, use } from "react"; import { useFormState } from "react-dom"; -export type Value = [ +export type UseActionProviderValue = [ payload: unknown, // TODO add info about the action ]; -export const UseActionContext = createContext(null); +export const UseActionContext = createContext( + null, +); export function useAction( action: (state: Awaited, payload: Payload) => State, @@ -44,3 +46,14 @@ export function useAction( return [dispatch as any, payload, state]; } + +export function UseActionProvider(props: { + children: React.ReactNode; + value: UseActionProviderValue | null; +}) { + return ( + + {props.children} + + ); +} diff --git a/use-action/lib/requestStorage.ts b/use-action/lib/requestStorage.ts index cdeaa37..ea4e6ca 100644 --- a/use-action/lib/requestStorage.ts +++ b/use-action/lib/requestStorage.ts @@ -7,7 +7,6 @@ interface RequestStoreWithActionPayload extends RequestStore { actionPayload?: unknown; } export function getRequestStorage() { - console.log("hello--------------"); const storage = getExpectedRequestStore("getRequestStorage"); return storage as RequestStoreWithActionPayload; diff --git a/use-action/server.ts b/use-action/server.ts index a7e9050..c7600fd 100644 --- a/use-action/server.ts +++ b/use-action/server.ts @@ -1,8 +1,10 @@ export const ENV = "server" as string; +export { getUseActionProviderValue } from "./context/server"; + +export { useAction, UseActionProvider } from "./context/shared"; + export { getRequestStorage } from "./lib/requestStorage"; export { createAction } from "./lib/createAction"; export { betterRedirect } from "./lib/betterRedirect"; -export { useAction } from "./context/shared"; -export { UseActionProvider } from "./context/server"; From 45004880036525f65bb2168cbbf7b21e7aff05f6 Mon Sep 17 00:00:00 2001 From: KATT Date: Sun, 31 Mar 2024 19:12:30 +0200 Subject: [PATCH 04/18] wip --- app/layout.tsx | 1 + app/page.tsx | 2 ++ use-action/browser.ts | 8 +------- use-action/context/server.tsx | 3 ++- 4 files changed, 6 insertions(+), 8 deletions(-) diff --git a/app/layout.tsx b/app/layout.tsx index 68f6712..b359738 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -19,6 +19,7 @@ export default function RootLayout( }>, ) { const value = getUseActionProviderValue(); + console.log({ value }); return ( diff --git a/app/page.tsx b/app/page.tsx index 3b1a3f6..c6c3c15 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -26,6 +26,8 @@ function ErrorToastOrBox(props: { children: JSX.Element }) { function CreateUserForm() { const [action, input, output] = useAction(createUser); + console.log({ action, input, output }); + return ( // 😷 `` makes the form work differently with or without JS enabled (inputs should clear) diff --git a/use-action/browser.ts b/use-action/browser.ts index d2650aa..62315b2 100644 --- a/use-action/browser.ts +++ b/use-action/browser.ts @@ -1,9 +1,3 @@ -import { UseActionProviderValue } from "./context/shared"; - export const ENV = "browser" as string; -export { useAction, UseActionProvider } from "./context/shared"; - -export function getUseActionProviderValue(): UseActionProviderValue | null { - return null; -} +export { UseActionProvider, useAction } from "./context/shared"; diff --git a/use-action/context/server.tsx b/use-action/context/server.tsx index 6951a81..a256505 100644 --- a/use-action/context/server.tsx +++ b/use-action/context/server.tsx @@ -3,8 +3,9 @@ import { UseActionProviderValue } from "./shared"; export function getUseActionProviderValue(): UseActionProviderValue | null { const storage = getRequestStorage(); + console.log("FROM SERVER", storage.actionPayload); - if (storage.actionPayload) { + if (!("actionPayload" in storage)) { return null; } From 68063c033e98b332f4627fa35af1aa94270e7946 Mon Sep 17 00:00:00 2001 From: KATT Date: Sun, 31 Mar 2024 19:20:23 +0200 Subject: [PATCH 05/18] meep --- use-action/lib/createAction.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/use-action/lib/createAction.ts b/use-action/lib/createAction.ts index 3d8be6d..c03ea71 100644 --- a/use-action/lib/createAction.ts +++ b/use-action/lib/createAction.ts @@ -1,5 +1,8 @@ import { storeActionPayload } from "./requestStorage"; +/** + * Wraps an action function to store its input as action payload in the request storage. + */ export function createAction() { return function action(fn: (input: TInput) => Promise) { return async function wrapper(...args: unknown[]) { From 968e65bd17a9c288b1e1491aa550bbd57e018ef4 Mon Sep 17 00:00:00 2001 From: KATT Date: Sun, 31 Mar 2024 19:37:13 +0200 Subject: [PATCH 06/18] wip --- README.md | 78 ++++++++++++++++----------------------------- app/_createUser.tsx | 1 + app/layout.tsx | 4 +-- app/page.tsx | 3 +- 4 files changed, 32 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index b891bf8..273d29c 100644 --- a/README.md +++ b/README.md @@ -1,61 +1,39 @@ -# Challenges with the `useFormState()` & `useActionState()` hooks +# Evening-hack alternative to `useActionState()` / `useFormState()` -One of the key advantages, for me, of `useFormState()`/`useActionState()` and `` is their ability to create isomorphic/universal forms that are progressively enhanced (fancy words for saying that forms work well with and without JS). +## Summary -However, the current API lacks some nuance needed for isomorphic forms. This repository aims to showcase those issues. +- No notion of "default state" when calling the hook +- No need of returning input values in order to re-render ``s values in SSR -- **tl;dr:** Search the code for "😷" to see my perceived issues. -- **tl;dr2:** Most of my headaches would disappear if `useFormState()`/`useActionState()` returned `Payload` which would be the data that was last successfully sent to the server. +### API -> Either I'm dumb or the API needs some refining. Maybe "current" values and errors should be stored in like a session variable? Maybe the server actions API isn't designed for returning anything at all? Should it be a Redirect ([Post/Get/Redirect](https://en.wikipedia.org/wiki/Post/Redirect/Get)) on every response? But then it will be weird when JS is enabled. - -## Clone it the example - -```sh -git clone git@github.com:KATT/react-server-action-useActionState-useFormState-issues.git -cd react-server-action-useActionState-useFormState-issues -pnpm i -pnpm dev -``` - -## Prior art on discussion - -- https://allanlasser.com/posts/2024-01-26-avoid-using-reacts-useformstatus -- https://github.com/facebook/react/pull/28491#issuecomment-2015032940 -- https://github.com/facebook/react/pull/28491#issuecomment-2015585371 -- _[... something I'm missing?]_ - -## Proposed API - -```ts -function useActionState( - action: (state: Awaited, payload: Payload) => State, - permalink?: string, +```tsx +function useAction( + action: (state: Awaited, payload: Payload) => State, + permalink?: string, ): [ - dispatch: (payload: Payload) => Promise, - // "input" - payload: null | Payload, - // "output" - state: State, - pending: boolean; + /** + * The action to use in a `` element. + */ + dispatch: (payload: Payload) => State, + /** + * Will be `null` if no payload is available. + */ + payload: null | Payload, + /** + * Will be `null` if no state is available. + */ + state: Awaited | null, ]; ``` -- Add `payload` to the return of the hook - - it's good to use to set `defaultValue={}` - - serializing the payload from the action is gnarly as shown in this example - - returning payload for setting `defaultValue` is unnecessary - - (payload can be stripped from e.g. `File` etc which can't really be hydrated) -- Get rid of `State` as a required argument -- Get rid of the `State` argument on the server actions. - - It gets rid of the `State` being serialized in the input that is passed back-and-forth (that to me isn't even needed?) - - It even requires a disclaimer [like this](https://react.dev/reference/react-dom/hooks/useFormState#my-action-can-no-longer-read-the-submitted-form-data) - - It changes the server-side depending on _how_ you call it, which is kinda odd - - It goes against React's fundamental idea that components are "Lego bricks" that can be added wherever and the functionality is baked in. Server Actions could be part of npm libraries, but it's frail with the current design. +#### Setting it up + +##### `/app/layout.tsx` -Additional: +> Obviously, I don't think any of this should be needed if React/Next.js provided a more sound primitive -- Make ``s with JavaScript mimic the behavior they have with JavaScript disabled (confirmed that it will changed by [@acdlite here](https://github.com/facebook/react/pull/28491#issuecomment-2015283772)) -- Maybe make `useActionState()` return an options object instead? 4 tuple values is a lot to keep track of (but that's easy to build abstractions that does) +## How this hack works -> With the above API, it will be possible to make great forms in React without using any form libraries +- `createAction()` wrapper hacks into Next.js' request storage and stores an `.actionPayload` with the submitted `FormData` +- `` is used to populate this to the `useAction()` handler diff --git a/app/_createUser.tsx b/app/_createUser.tsx index 6a60c86..e5dc5e5 100644 --- a/app/_createUser.tsx +++ b/app/_createUser.tsx @@ -21,6 +21,7 @@ export const createUser = createAction()(async (payload) => { } if (Object.keys(errors).length > 0) { + // ✅ I don't need to return anything about the payload here return { errors, }; diff --git a/app/layout.tsx b/app/layout.tsx index b359738..42a8ca5 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -18,10 +18,8 @@ export default function RootLayout( children: React.ReactNode; }>, ) { - const value = getUseActionProviderValue(); - console.log({ value }); return ( - + {props.children} diff --git a/app/page.tsx b/app/page.tsx index c6c3c15..daea243 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -25,6 +25,7 @@ function ErrorToastOrBox(props: { children: JSX.Element }) { } function CreateUserForm() { const [action, input, output] = useAction(createUser); + // ^? console.log({ action, input, output }); @@ -49,7 +50,7 @@ function CreateUserForm() { id="username" name="username" placeholder="john" - // 😷 how come I have to return this from the backend / server action? It should be readily available in both places + // ✅ I can just grab `input` here defaultValue={input?.get("username") as string} className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" required From 55943159220c0d82b8653747971e4b8662d2cb6f Mon Sep 17 00:00:00 2001 From: KATT Date: Sun, 31 Mar 2024 19:37:43 +0200 Subject: [PATCH 07/18] wip --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 273d29c..73ee589 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ function useAction( > Obviously, I don't think any of this should be needed if React/Next.js provided a more sound primitive +https://github.com/KATT/react-server-action-useActionState-useFormState-issues/blob/968e65bd17a9c288b1e1491aa550bbd57e018ef4/app/layout.tsx#L22 + ## How this hack works - `createAction()` wrapper hacks into Next.js' request storage and stores an `.actionPayload` with the submitted `FormData` From b102b5ae5d5a0354f0bcaf5a9d130bfbe63c7c6f Mon Sep 17 00:00:00 2001 From: KATT Date: Sun, 31 Mar 2024 19:40:33 +0200 Subject: [PATCH 08/18] meow --- README.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 73ee589..dc634b3 100644 --- a/README.md +++ b/README.md @@ -29,11 +29,15 @@ function useAction( #### Setting it up -##### `/app/layout.tsx` +> Obviously, I don't think any of this should be needed if React/Next.js provided -> Obviously, I don't think any of this should be needed if React/Next.js provided a more sound primitive +- https://github.com/KATT/react-server-action-useActionState-useFormState-issues/blob/55943159220c0d82b8653747971e4b8662d2cb6f/app/layout.tsx#L22 +- https://github.com/KATT/react-server-action-useActionState-useFormState-issues/blob/55943159220c0d82b8653747971e4b8662d2cb6f/app/page.tsx#L27 +- https://github.com/KATT/react-server-action-useActionState-useFormState-issues/blob/55943159220c0d82b8653747971e4b8662d2cb6f/app/_createUser.tsx#L11 -https://github.com/KATT/react-server-action-useActionState-useFormState-issues/blob/968e65bd17a9c288b1e1491aa550bbd57e018ef4/app/layout.tsx#L22 +Yay, now I can just use `input?.get("username")`: + +https://github.com/KATT/react-server-action-useActionState-useFormState-issues/blob/55943159220c0d82b8653747971e4b8662d2cb6f/app/page.tsx#L54C20-L54C42 ## How this hack works From 775de1bd29d9442604742fb179d55980cb4efefd Mon Sep 17 00:00:00 2001 From: KATT Date: Sun, 31 Mar 2024 19:42:07 +0200 Subject: [PATCH 09/18] ok --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index dc634b3..1ea19f7 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ ## Summary +- Background, see the [README of the `main`-branch](https://github.com/KATT/react-server-action-useActionState-useFormState-issues/tree/main) - No notion of "default state" when calling the hook - No need of returning input values in order to re-render ``s values in SSR From a1fc64e58174e35d8c7f3bc0c26031e8bb0cc0bb Mon Sep 17 00:00:00 2001 From: KATT Date: Sun, 31 Mar 2024 19:42:19 +0200 Subject: [PATCH 10/18] ok --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1ea19f7..f4f5fc9 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ## Summary -- Background, see the [README of the `main`-branch](https://github.com/KATT/react-server-action-useActionState-useFormState-issues/tree/main) +- **Background:** see the [README of the `main`-branch](https://github.com/KATT/react-server-action-useActionState-useFormState-issues/tree/main) - No notion of "default state" when calling the hook - No need of returning input values in order to re-render ``s values in SSR From f9bf79941693b1f966232970fbce8f60036c5e3e Mon Sep 17 00:00:00 2001 From: KATT Date: Sun, 31 Mar 2024 19:45:13 +0200 Subject: [PATCH 11/18] mkay --- README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/README.md b/README.md index f4f5fc9..52e3bdf 100644 --- a/README.md +++ b/README.md @@ -44,3 +44,20 @@ https://github.com/KATT/react-server-action-useActionState-useFormState-issues/b - `createAction()` wrapper hacks into Next.js' request storage and stores an `.actionPayload` with the submitted `FormData` - `` is used to populate this to the `useAction()` handler + +## What we're missing here + +This work is mainly focused on enhancing the "no-JS-experience": + +- ``'s should clear after submission in JS. +- `payload` is always `null` in the client, will be only be a problem when forms actually clear + +### Play with it + +```sh +git clone git@github.com:KATT/react-server-action-useActionState-useFormState-issues.git +cd react-server-action-useActionState-useFormState-issues +git checkout feat/hack +pnpm i +pnpm dev +``` From 077dc93bce0dee9596dd6e4ee8549ad73be8147c Mon Sep 17 00:00:00 2001 From: KATT Date: Sun, 31 Mar 2024 19:47:42 +0200 Subject: [PATCH 12/18] mkay --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 52e3bdf..d90ba2c 100644 --- a/README.md +++ b/README.md @@ -49,8 +49,9 @@ https://github.com/KATT/react-server-action-useActionState-useFormState-issues/b This work is mainly focused on enhancing the "no-JS-experience": -- ``'s should clear after submission in JS. -- `payload` is always `null` in the client, will be only be a problem when forms actually clear +- ``'s should clear after submission in JS +- `payload` is always `null` in the client, will be more highlighted when form clears in JS as well +- `payload` isn't tied to a specific action at the moment - `useAction(x)` shouldn't return payload of `useAction(y)` ### Play with it From bb5c53788973a954c5549c846f711bc0ba15611b Mon Sep 17 00:00:00 2001 From: KATT Date: Sun, 31 Mar 2024 19:48:53 +0200 Subject: [PATCH 13/18] mkay --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index d90ba2c..eaa94cd 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ This work is mainly focused on enhancing the "no-JS-experience": - ``'s should clear after submission in JS - `payload` is always `null` in the client, will be more highlighted when form clears in JS as well - `payload` isn't tied to a specific action at the moment - `useAction(x)` shouldn't return payload of `useAction(y)` +- this is a hack, there's probably more ### Play with it From 1ac2855041de480507dcdf5fc645413d577b615d Mon Sep 17 00:00:00 2001 From: KATT Date: Sun, 31 Mar 2024 19:50:37 +0200 Subject: [PATCH 14/18] fix --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index eaa94cd..6ad08f4 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ function useAction( #### Setting it up -> Obviously, I don't think any of this should be needed if React/Next.js provided +> Obviously, I don't think any of this wiring should be needed and that `payload` should be omnipresent in `useFormState()` - https://github.com/KATT/react-server-action-useActionState-useFormState-issues/blob/55943159220c0d82b8653747971e4b8662d2cb6f/app/layout.tsx#L22 - https://github.com/KATT/react-server-action-useActionState-useFormState-issues/blob/55943159220c0d82b8653747971e4b8662d2cb6f/app/page.tsx#L27 @@ -38,7 +38,7 @@ function useAction( Yay, now I can just use `input?.get("username")`: -https://github.com/KATT/react-server-action-useActionState-useFormState-issues/blob/55943159220c0d82b8653747971e4b8662d2cb6f/app/page.tsx#L54C20-L54C42 +https://github.com/KATT/react-server-action-useActionState-useFormState-issues/blob/bb5c53788973a954c5549c846f711bc0ba15611b/app/page.tsx#L54 ## How this hack works From 4676bd75fa1ee9262bc3e60e3e2c9dcff4ac7368 Mon Sep 17 00:00:00 2001 From: KATT Date: Sun, 31 Mar 2024 20:29:53 +0200 Subject: [PATCH 15/18] meep --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 6ad08f4..baca2da 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ - **Background:** see the [README of the `main`-branch](https://github.com/KATT/react-server-action-useActionState-useFormState-issues/tree/main) - No notion of "default state" when calling the hook - No need of returning input values in order to re-render ``s values in SSR +- No two argument on actions, there's only 1 - your input ### API From aae33fe890dd0706d7988e5f49921c14c68b0892 Mon Sep 17 00:00:00 2001 From: KATT Date: Sun, 31 Mar 2024 20:30:30 +0200 Subject: [PATCH 16/18] wip --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index baca2da..4b1da46 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,11 @@ function useAction( ]; ``` +#### Usage + +- https://github.com/KATT/react-server-action-useActionState-useFormState-issues/blob/bb5c53788973a954c5549c846f711bc0ba15611b/app/page.tsx#L27 +- https://github.com/KATT/react-server-action-useActionState-useFormState-issues/blob/bb5c53788973a954c5549c846f711bc0ba15611b/app/page.tsx#L54 + #### Setting it up > Obviously, I don't think any of this wiring should be needed and that `payload` should be omnipresent in `useFormState()` From e1329e7a36a74c629134349c44f6162c107c738e Mon Sep 17 00:00:00 2001 From: KATT Date: Sun, 31 Mar 2024 20:38:20 +0200 Subject: [PATCH 17/18] cool --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4b1da46..6df682d 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ - **Background:** see the [README of the `main`-branch](https://github.com/KATT/react-server-action-useActionState-useFormState-issues/tree/main) - No notion of "default state" when calling the hook - No need of returning input values in order to re-render ``s values in SSR -- No two argument on actions, there's only 1 - your input +- Not 2 arguments on actions, there's only 1 - your input ### API From 0501d9e2c0f13e377100d8ca3967ee7a24be2ec2 Mon Sep 17 00:00:00 2001 From: KATT Date: Thu, 4 Apr 2024 18:18:23 +0200 Subject: [PATCH 18/18] case insensitive --- app/_createUser.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/_createUser.tsx b/app/_createUser.tsx index e5dc5e5..3db8e14 100644 --- a/app/_createUser.tsx +++ b/app/_createUser.tsx @@ -16,7 +16,7 @@ export const createUser = createAction()(async (payload) => { const values = Object.fromEntries(payload); const errors: ValidationErrors = {}; - if (usernames.includes(values.username as string)) { + if (usernames.includes((values.username as string).trim().toLowerCase())) { errors.username = `Username '${values.username}' is already taken`; }