diff --git a/examples/06_form-demo/.gitignore b/examples/06_form-demo/.gitignore new file mode 100644 index 000000000..ad583432d --- /dev/null +++ b/examples/06_form-demo/.gitignore @@ -0,0 +1,7 @@ +node_modules +dist +.env* +*.tsbuildinfo +.cache +.DS_Store +*.pem diff --git a/examples/06_form-demo/package.json b/examples/06_form-demo/package.json new file mode 100644 index 000000000..8d21174d6 --- /dev/null +++ b/examples/06_form-demo/package.json @@ -0,0 +1,24 @@ +{ + "name": "06_form-demo", + "version": "0.1.0", + "type": "module", + "private": true, + "scripts": { + "dev": "waku dev", + "build": "waku build", + "start": "waku start" + }, + "dependencies": { + "react": "19.0.0", + "react-dom": "19.0.0", + "react-server-dom-webpack": "19.0.0", + "waku": "0.21.17" + }, + "devDependencies": { + "@types/react": "19.0.8", + "@types/react-dom": "19.0.3", + "autoprefixer": "10.4.20", + "tailwindcss": "3.4.17", + "typescript": "5.7.3" + } +} diff --git a/examples/06_form-demo/postcss.config.js b/examples/06_form-demo/postcss.config.js new file mode 100644 index 000000000..709af5d83 --- /dev/null +++ b/examples/06_form-demo/postcss.config.js @@ -0,0 +1,7 @@ +/** @type {import('postcss-load-config').Config} */ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/examples/06_form-demo/private/message.txt b/examples/06_form-demo/private/message.txt new file mode 100644 index 000000000..f85ff7304 --- /dev/null +++ b/examples/06_form-demo/private/message.txt @@ -0,0 +1 @@ +Hello from server! \ No newline at end of file diff --git a/examples/06_form-demo/public/images/favicon.png b/examples/06_form-demo/public/images/favicon.png new file mode 100644 index 000000000..cd90d7908 Binary files /dev/null and b/examples/06_form-demo/public/images/favicon.png differ diff --git a/examples/06_form-demo/src/components/Form.tsx b/examples/06_form-demo/src/components/Form.tsx new file mode 100644 index 000000000..ea1e97ee0 --- /dev/null +++ b/examples/06_form-demo/src/components/Form.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { useFormStatus } from 'react-dom'; + +const SubmitButton = () => { + const { pending } = useFormStatus(); + return ( + <> + + + ); +}; + +export const Form = ({ + message, + greet, +}: { + message: Promise; + greet: (formData: FormData) => Promise; +}) => ( +
+

{message}

+
+
+
+ Name:{' '} + +
+
+ Email:{' '} + +
+ +
+
+

This is a client component.

+
+); diff --git a/examples/06_form-demo/src/components/ServerForm.tsx b/examples/06_form-demo/src/components/ServerForm.tsx new file mode 100644 index 000000000..05b87d1d1 --- /dev/null +++ b/examples/06_form-demo/src/components/ServerForm.tsx @@ -0,0 +1,67 @@ +async function submitUserProfile(formData: FormData) { + 'use server'; + const name = formData.get('name'); + const age = formData.get('age'); + const favoriteColor = formData.get('favoriteColor'); + const hobby = formData.get('hobby'); + const isSubscribed = formData.get('newsletter') === 'on'; + + console.log({ + name, + age, + favoriteColor, + hobby, + isSubscribed, + }); +} + +export const ServerForm = () => { + return ( +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + +
+ ); +}; diff --git a/examples/06_form-demo/src/components/footer.tsx b/examples/06_form-demo/src/components/footer.tsx new file mode 100644 index 000000000..8cfd9c897 --- /dev/null +++ b/examples/06_form-demo/src/components/footer.tsx @@ -0,0 +1,18 @@ +export const Footer = () => { + return ( + + ); +}; diff --git a/examples/06_form-demo/src/components/funcs.ts b/examples/06_form-demo/src/components/funcs.ts new file mode 100644 index 000000000..822cf91d3 --- /dev/null +++ b/examples/06_form-demo/src/components/funcs.ts @@ -0,0 +1,24 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { unstable_rerenderRoute } from 'waku/router/server'; + +export const getMessage = async () => { + const data = await readFile('./private/message.txt', 'utf8'); + return data; +}; + +export const greet = async (formData: FormData) => { + 'use server'; + // simulate a slow server response + await new Promise((resolve) => setTimeout(resolve, 1000)); + const currentData = await getMessage(); + await writeFile( + './private/message.txt', + currentData + '\n' + formData.get('name') + ' from server!', + ); + unstable_rerenderRoute('/'); +}; + +export const increment = async (count: number) => { + 'use server'; + return count + 1; +}; diff --git a/examples/06_form-demo/src/components/header.tsx b/examples/06_form-demo/src/components/header.tsx new file mode 100644 index 000000000..13ddf7b5e --- /dev/null +++ b/examples/06_form-demo/src/components/header.tsx @@ -0,0 +1,19 @@ +import { Link } from 'waku'; + +export const Header = () => { + return ( +
+

+ Waku form demo +

+ + (source) + +
+ ); +}; diff --git a/examples/06_form-demo/src/pages/_layout.tsx b/examples/06_form-demo/src/pages/_layout.tsx new file mode 100644 index 000000000..78f1b0452 --- /dev/null +++ b/examples/06_form-demo/src/pages/_layout.tsx @@ -0,0 +1,40 @@ +import '../styles.css'; + +import type { ReactNode } from 'react'; + +import { Header } from '../components/header'; +import { Footer } from '../components/footer'; + +type RootLayoutProps = { children: ReactNode }; + +export default async function RootLayout({ children }: RootLayoutProps) { + const data = await getData(); + + return ( +
+ Waku + + +
+
+ {children} +
+
+
+ ); +} + +const getData = async () => { + const data = { + description: 'An internet website!', + icon: '/images/favicon.png', + }; + + return data; +}; + +export const getConfig = async () => { + return { + render: 'static', + } as const; +}; diff --git a/examples/06_form-demo/src/pages/index.tsx b/examples/06_form-demo/src/pages/index.tsx new file mode 100644 index 000000000..a049ca53d --- /dev/null +++ b/examples/06_form-demo/src/pages/index.tsx @@ -0,0 +1,24 @@ +import { Form } from '../components/Form'; +import { getMessage, greet } from '../components/funcs'; +import { ServerForm } from '../components/ServerForm'; + +export default function HomePage() { + return ( +
+
+

Server Form

+ +
+
+

Client Form

+
+
+
+ ); +} + +export const getConfig = async () => { + return { + render: 'dynamic', + } as const; +}; diff --git a/examples/06_form-demo/src/styles.css b/examples/06_form-demo/src/styles.css new file mode 100644 index 000000000..06dc50147 --- /dev/null +++ b/examples/06_form-demo/src/styles.css @@ -0,0 +1,5 @@ +@import url('https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,400;0,700;1,400;1,700&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Zen+Maru+Gothic:wght@400;700&display=swap'); +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/examples/06_form-demo/tailwind.config.js b/examples/06_form-demo/tailwind.config.js new file mode 100644 index 000000000..b15775b26 --- /dev/null +++ b/examples/06_form-demo/tailwind.config.js @@ -0,0 +1,10 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./src/**/*.{js,jsx,ts,tsx}'], + theme: { + fontFamily: { + nunito: ['"Nunito"', 'sans-serif'], + 'zen-maru-gothic': ['"Zen Maru Gothic"', 'serif'], + }, + }, +}; diff --git a/examples/06_form-demo/tsconfig.json b/examples/06_form-demo/tsconfig.json new file mode 100644 index 000000000..33d25f480 --- /dev/null +++ b/examples/06_form-demo/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "strict": true, + "target": "esnext", + "downlevelIteration": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "skipLibCheck": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "types": ["node", "react/experimental"], + "jsx": "react-jsx" + } +} diff --git a/examples/36_form/src/components/App.tsx b/examples/36_form/src/components/App.tsx index afb38d4ff..879340765 100644 --- a/examples/36_form/src/components/App.tsx +++ b/examples/36_form/src/components/App.tsx @@ -15,8 +15,8 @@ const App = ({ name }: { name: string }) => { >

Hello {name}!!

This is a server component.

- + diff --git a/packages/waku/src/lib/renderers/html.ts b/packages/waku/src/lib/renderers/html.ts index 5f81bd76d..383053e88 100644 --- a/packages/waku/src/lib/renderers/html.ts +++ b/packages/waku/src/lib/renderers/html.ts @@ -158,6 +158,9 @@ const rectifyHtml = () => { }); }; +// FIXME Why does it error on the rist time? +let hackToIgnoreTheVeryFirstError = true; + export async function renderHtml( config: PureConfig, ctx: Pick, @@ -220,34 +223,53 @@ export async function renderHtml( const htmlNode: Promise = createFromReadableStream(htmlStream, { serverConsumerManifest: { moduleMap, moduleLoading: null }, }); - const readable = await renderToReadableStream( - createElement( - ServerRoot as FunctionComponent< - Omit, 'children'> - >, - { elements: elementsPromise }, - htmlNode as any, - ), - { - formState: - actionResult === undefined - ? null - : await getExtractFormState(ctx)(actionResult), - onError(err: unknown) { - console.error(err); + try { + const readable = await renderToReadableStream( + createElement( + ServerRoot as FunctionComponent< + Omit, 'children'> + >, + { elements: elementsPromise }, + htmlNode as any, + ), + { + formState: + actionResult === undefined + ? null + : await getExtractFormState(ctx)(actionResult), + onError(err: unknown) { + if (hackToIgnoreTheVeryFirstError) { + return; + } + console.error(err); + }, }, - }, - ); - const injected: ReadableStream & { allReady?: Promise } = readable - .pipeThrough(rectifyHtml()) - .pipeThrough( - injectHtmlHead( - config.basePath + config.rscBase + '/' + encodeRscPath(rscPath), + ); + const injected: ReadableStream & { allReady?: Promise } = readable + .pipeThrough(rectifyHtml()) + .pipeThrough( + injectHtmlHead( + config.basePath + config.rscBase + '/' + encodeRscPath(rscPath), + htmlHead, + isDev ? `${config.basePath}${config.srcDir}/${SRC_MAIN}` : '', + ), + ) + .pipeThrough(injectRSCPayload(stream2)); + injected.allReady = readable.allReady; + return injected as never; + } catch (e) { + if (hackToIgnoreTheVeryFirstError) { + hackToIgnoreTheVeryFirstError = false; + return renderHtml( + config, + ctx, htmlHead, - isDev ? `${config.basePath}${config.srcDir}/${SRC_MAIN}` : '', - ), - ) - .pipeThrough(injectRSCPayload(stream2)); - injected.allReady = readable.allReady; - return injected as never; + elements, + html, + rscPath, + actionResult, + ); + } + throw e; + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f37e4de7..9dd2cfbcd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -669,6 +669,37 @@ importers: specifier: 5.7.3 version: 5.7.3 + examples/06_form-demo: + dependencies: + react: + specifier: 19.0.0 + version: 19.0.0 + react-dom: + specifier: 19.0.0 + version: 19.0.0(react@19.0.0) + react-server-dom-webpack: + specifier: 19.0.0 + version: 19.0.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(webpack@5.97.1) + waku: + specifier: 0.21.17 + version: link:../../packages/waku + devDependencies: + '@types/react': + specifier: 19.0.8 + version: 19.0.8 + '@types/react-dom': + specifier: 19.0.3 + version: 19.0.3(@types/react@19.0.8) + autoprefixer: + specifier: 10.4.20 + version: 10.4.20(postcss@8.5.1) + tailwindcss: + specifier: 3.4.17 + version: 3.4.17 + typescript: + specifier: 5.7.3 + version: 5.7.3 + examples/11_fs-router: dependencies: react: @@ -1265,7 +1296,7 @@ importers: version: 7.4.3 tsup: specifier: ^8.3.5 - version: 8.3.5(@swc/core@1.10.9)(jiti@2.4.2)(postcss@8.5.1)(tsx@4.19.2)(typescript@5.7.3)(yaml@2.7.0) + version: 8.3.5(@swc/core@1.10.9(@swc/helpers@0.5.15))(jiti@2.4.2)(postcss@8.5.1)(tsx@4.19.2)(typescript@5.7.3)(yaml@2.7.0) update-check: specifier: ^1.5.4 version: 1.5.4 @@ -3168,11 +3199,6 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.24.3: - resolution: {integrity: sha512-1CPmv8iobE2fyRMV97dAcMVegvvWKxmq94hkLiAkUGwKVTyDLw33K+ZxiFrREKmmps4rIw6grcCFCnTMSZ/YiA==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - browserslist@4.24.4: resolution: {integrity: sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -3233,9 +3259,6 @@ packages: resolution: {integrity: sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==} engines: {node: '>=14.16'} - caniuse-lite@1.0.30001690: - resolution: {integrity: sha512-5ExiE3qQN6oF8Clf8ifIDcMRCRE/dMGcETG/XGMD8/XiXm6HXQgQTh1yZYLXXpSOsEUlJm1Xr7kGULZTuGtP/w==} - caniuse-lite@1.0.30001695: resolution: {integrity: sha512-vHyLade6wTgI2u1ec3WQBxv+2BrTERV28UXQu9LO6lZ9pYeMk34vjXFLOxo1A4UBA8XTL4njRQZdno/yYaSmWw==} @@ -3526,9 +3549,6 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - electron-to-chromium@1.5.77: - resolution: {integrity: sha512-AnJSrt5JpRVgY6dgd5yccguLc5A7oMSF0Kt3fcW+Hp5WTuFbl5upeSFZbMZYy2o7jhmIhU8Ekrd82GhyXUqUUg==} - electron-to-chromium@1.5.86: resolution: {integrity: sha512-/D7GAAaCRBQFBBcop6SfAAGH37djtpWkOuYhyAajw0l5vsfeSsUQYxaFPwr1c/mC/flARCDdKFo5gpFqNI+18w==} @@ -5710,12 +5730,6 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} - update-browserslist-db@1.1.1: - resolution: {integrity: sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - update-browserslist-db@1.1.2: resolution: {integrity: sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg==} hasBin: true @@ -6124,7 +6138,7 @@ snapshots: dependencies: '@babel/compat-data': 7.26.3 '@babel/helper-validator-option': 7.25.9 - browserslist: 4.24.3 + browserslist: 4.24.4 lru-cache: 5.1.1 semver: 6.3.1 @@ -7720,13 +7734,6 @@ snapshots: dependencies: fill-range: 7.1.1 - browserslist@4.24.3: - dependencies: - caniuse-lite: 1.0.30001690 - electron-to-chromium: 1.5.77 - node-releases: 2.0.19 - update-browserslist-db: 1.1.1(browserslist@4.24.3) - browserslist@4.24.4: dependencies: caniuse-lite: 1.0.30001695 @@ -7787,8 +7794,6 @@ snapshots: camelcase@7.0.1: {} - caniuse-lite@1.0.30001690: {} - caniuse-lite@1.0.30001695: {} capnp-ts@0.7.0: @@ -8029,8 +8034,6 @@ snapshots: eastasianwidth@0.2.0: {} - electron-to-chromium@1.5.77: {} - electron-to-chromium@1.5.86: {} emoji-regex-xs@1.0.0: {} @@ -8294,7 +8297,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.0(@typescript-eslint/parser@8.21.0(eslint@9.18.0(jiti@2.4.2))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.7.0(eslint-plugin-import@2.31.0)(eslint@9.18.0(jiti@2.4.2)))(eslint@9.18.0(jiti@2.4.2)): + eslint-module-utils@2.12.0(@typescript-eslint/parser@8.21.0(eslint@9.18.0(jiti@2.4.2))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.7.0)(eslint@9.18.0(jiti@2.4.2)): dependencies: debug: 3.2.7 optionalDependencies: @@ -8316,7 +8319,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.18.0(jiti@2.4.2) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.21.0(eslint@9.18.0(jiti@2.4.2))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.7.0(eslint-plugin-import@2.31.0)(eslint@9.18.0(jiti@2.4.2)))(eslint@9.18.0(jiti@2.4.2)) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.21.0(eslint@9.18.0(jiti@2.4.2))(typescript@5.7.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.7.0)(eslint@9.18.0(jiti@2.4.2)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -10615,7 +10618,7 @@ snapshots: tslib@2.8.1: {} - tsup@8.3.5(@swc/core@1.10.9)(jiti@2.4.2)(postcss@8.5.1)(tsx@4.19.2)(typescript@5.7.3)(yaml@2.7.0): + tsup@8.3.5(@swc/core@1.10.9(@swc/helpers@0.5.15))(jiti@2.4.2)(postcss@8.5.1)(tsx@4.19.2)(typescript@5.7.3)(yaml@2.7.0): dependencies: bundle-require: 5.1.0(esbuild@0.24.2) cac: 6.7.14 @@ -10788,12 +10791,6 @@ snapshots: universalify@2.0.1: {} - update-browserslist-db@1.1.1(browserslist@4.24.3): - dependencies: - browserslist: 4.24.3 - escalade: 3.2.0 - picocolors: 1.1.1 - update-browserslist-db@1.1.2(browserslist@4.24.4): dependencies: browserslist: 4.24.4