nextjs-demo
next.js demo using react 19 rc
git clone https://9o.is/git/nextjs-demo.git
commit 0584ae2f33449eb52e071f9f41ba8a444823dcd2 parent 5f05b3fa96c0a473a637cf0f7c3d7e639a243795 Author: Jul <jul@9o.is> Date: Fri, 26 Dec 2025 07:14:43 -0500 add latest changes Diffstat:
| M | src/app/events/page.tsx | | | 16 | +++++++++++++--- |
| A | src/components/ErrorBoundary.tsx | | | 37 | +++++++++++++++++++++++++++++++++++++ |
| M | src/components/headless/Alert.tsx | | | 81 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----- |
| M | src/components/headless/ComboBox.tsx | | | 10 | ++++++---- |
| M | src/components/headless/Form.tsx | | | 20 | ++++++++++---------- |
| A | src/components/headless/Loading.tsx | | | 4 | ++++ |
| D | src/components/headless/Progress.tsx | | | 64 | ---------------------------------------------------------------- |
| M | src/components/headless/index.ts | | | 2 | +- |
| M | src/components/hooks/fetch.ts | | | 22 | ---------------------- |
| M | src/components/page/EventsPage/EventsPage.tsx | | | 91 | ++++++++++++++++++++++++++++++++++++++++++++----------------------------------- |
| A | src/components/page/EventsPage/eventsAction.ts | | | 6 | ++++++ |
| M | src/components/page/EventsPage/useFilteredSHEvents.ts | | | 7 | ++++--- |
| M | src/lib/objects.ts | | | 4 | ++++ |
13 files changed, 212 insertions(+), 152 deletions(-)
diff --git a/src/app/events/page.tsx b/src/app/events/page.tsx @@ -1,12 +1,22 @@ import { EventsPage } from "@/components/page" -import { getEvents, SHNodeEvent } from "@/components/page/EventsPage/useSHEvents" +import { getEvents, SHEvent, SHNodeEvent } from "@/components/page/EventsPage/useSHEvents" async function getSHEvents() { const res = await fetch('http://localhost:3000/node-events/node-a') + // await new Promise((resolve) => setTimeout(resolve, 9000)) const node: SHNodeEvent = await res.json() return getEvents(node) } -export default () => { - return <EventsPage loadedEvents={getSHEvents()} /> +async function getCities(events: Promise<SHEvent[]>) { + return [...new Set((await events).map(({ city }) => city))] +} + +export default async () => { + const events = getSHEvents() + const cities = getCities(events) + + return ( + <EventsPage events={events} cities={cities} /> + ) } \ No newline at end of file diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx @@ -0,0 +1,37 @@ +import React, { ReactNode } from "react" +import { Alert } from "./headless/Alert" + +type ErrorBoundaryProps = { + children: ReactNode +} + +type ErrorBoundaryState = { + error?: Error +} + +export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> { + + constructor(props: ErrorBoundaryProps) { + super(props) + this.state = {} + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { error } + } + + componentDidCatch(error: any, errorInfo: any) { + console.log('error boundaary', { error, errorInfo }) + } + + render() { + const { error } = this.state + const { children } = this.props + + return ( + <> + {/* {children} */} + </> + ) + } +} diff --git a/src/components/headless/Alert.tsx b/src/components/headless/Alert.tsx @@ -1,9 +1,81 @@ -import { memo } from "react" +import { Component, createContext, ReactNode, useContext, useState } from "react" + +type AlertBoundaryContext = { + error?: Error + setError: (error: Error) => void +} + +const AlertBoundaryContext = createContext<AlertBoundaryContext | undefined>(undefined) + +export const useAlertBoundaryContext = () => { + const context = useContext(AlertBoundaryContext) + if (!context) throw new Error('useAlertBoundaryContext() must be used with <AlertBoundary/>') + return context +} + +export const Alert = () => { + const { error } = useAlertBoundaryContext() -export const Alert = memo(({ error }: { error?: Error }) => { return ( <div role="alert"> - {error?.message ?? <> </>} + {error?.message ?? null} </div> ) -}) -\ No newline at end of file +} + +type AlertContextProps = { + children: ReactNode +} + +const AlertContext = ({ children }: AlertContextProps) => { + const [error, setError] = useState<Error>() + + return ( + <AlertBoundaryContext.Provider value={{ error, setError }}> + {children} + </AlertBoundaryContext.Provider> + ) +} + +type ErrorBoundaryProps = { + children: ReactNode + fallback?: ReactNode +} + +type ErrorBoundaryState = { + error?: Error +} + +export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> { + static contextType = AlertBoundaryContext + context!: React.ContextType<typeof AlertBoundaryContext>; + + constructor(props: ErrorBoundaryProps) { + super(props) + this.state = {} + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { error } + } + + componentDidCatch(error: Error) { + if (!this.context) throw new Error('<Alert.ErrorBoundary/> must be used within <Alert.Context/>') + this.context.setError(error) + console.log('error boundaary', error) + } + + render() { + const { error } = this.state + const { children, fallback } = this.props + + return ( + <> + {!error ? children : fallback} + </> + ) + } +} + +Alert.Context = AlertContext +Alert.ErrorBoundary = ErrorBoundary diff --git a/src/components/headless/ComboBox.tsx b/src/components/headless/ComboBox.tsx @@ -1,12 +1,14 @@ -import { useId, memo } from "react" +import { isPromise } from "@/lib" +import { useId, memo, use } from "react" export type ComboBoxProps = { label: string name: string - options: string[] + options: string[] | Promise<string[]> } - + export const ComboBox = memo(({ label, name, options }: ComboBoxProps) => { + const opts = isPromise(options) ? use(options) : options const inputId = useId() const datalistId = useId() @@ -15,7 +17,7 @@ export const ComboBox = memo(({ label, name, options }: ComboBoxProps) => { <label htmlFor={inputId}>{label}</label> <input id={inputId} name={name} type="text" list={datalistId} /> <datalist id={datalistId}> - {options.map(option => <option key={option} value={option} />)} + {opts.map(option => <option key={option} value={option} />)} </datalist> </> ) diff --git a/src/components/headless/Form.tsx b/src/components/headless/Form.tsx @@ -3,11 +3,12 @@ import { createContext, ReactNode, useContext, useRef, useState } from "react" type FormProps<T> = ({ children: ReactNode ['aria-label']?: string + action?: (formData: FormData) => void }) & ({ onSubmit?: (data: FormDataMap) => void validator: undefined } | { - onSubmit: (data: T) => void + onSubmit?: (data: T) => void validator: (data: FormDataMap) => T }) @@ -21,7 +22,7 @@ type FormContextProps = { const FormContext = createContext<FormContextProps | undefined>(undefined) -export function Form<T>({ onSubmit, validator, children, ...props }: FormProps<T>) { +export function Form<T>({ onSubmit, validator, children, action, ...props }: FormProps<T>) { const formRef = useRef<HTMLFormElement>(null) const [formError, setFormError] = useState<string | undefined>() @@ -29,22 +30,21 @@ export function Form<T>({ onSubmit, validator, children, ...props }: FormProps<T event.preventDefault() const formData = new FormData(formRef.current || undefined) - const data: FormDataMap = {} - - for (const [k, v] of formData.entries()) { - data[k] = v - } + const data: FormDataMap = Object.fromEntries(formData) if (validator) { try { - onSubmit(validator(data)) + const validData = validator(data) + onSubmit?.(validData) + action?.(formData) } catch (error) { setFormError((error as Error).message) } return + } else { + onSubmit?.(data) + action?.(formData) } - - onSubmit?.(data) } return ( diff --git a/src/components/headless/Loading.tsx b/src/components/headless/Loading.tsx @@ -0,0 +1,3 @@ +export const Loading = () => { + return <div>Loading ...</div> +} +\ No newline at end of file diff --git a/src/components/headless/Progress.tsx b/src/components/headless/Progress.tsx @@ -1,63 +0,0 @@ -import { createContext, ReactNode, useContext, useId } from "react" - -type ProgressContext = { - labelId: string - progressBarId: string - loading: boolean -} - -type ProgressProps = { - loading: boolean - children: ReactNode -} - -const ProgressContext = createContext<ProgressContext | undefined>(undefined) - -export function useProgressContext() { - const context = useContext(ProgressContext) - if (!context) throw new Error('useProgressContext must be used within a Progress component') - return context -} - -export function Progress ({ loading, children }: ProgressProps) { - const labelId = useId() - const progressBarId = useId() - - return ( - <ProgressContext.Provider value={{ labelId, progressBarId, loading }}> - {children} - </ProgressContext.Provider> - ) -} - -function ProgressBar({ children }: { children: ReactNode }) { - const { progressBarId, labelId, loading } = useProgressContext() - - if (!loading) return <> </> - - return ( - <div id={progressBarId} role="progressbar" aria-labelledby={labelId}> - {children} - </div> - ) -} - -function ProgressLabel({ children }: { children: ReactNode }) { - const { labelId } = useProgressContext() - return ( - <span id={labelId}>{children}</span> - ) -} - -function ProgressContent({ children }: { children: ReactNode }) { - const { loading, progressBarId } = useProgressContext() - return ( - <div aria-live="polite" aria-describedby={loading ? progressBarId : undefined}> - {children} - </div> - ) -} - -Progress.Bar = ProgressBar -Progress.Label = ProgressLabel -Progress.Content = ProgressContent -\ No newline at end of file diff --git a/src/components/headless/index.ts b/src/components/headless/index.ts @@ -3,5 +3,5 @@ export * from './Button' export * from './ComboBox' export * from './Checkbox' export * from './Form' -export * from './Progress' +export * from './Loading' export * from './Select' \ No newline at end of file diff --git a/src/components/hooks/fetch.ts b/src/components/hooks/fetch.ts @@ -35,24 +35,3 @@ export function useFetch<T>(url: string) { return { data, error, loading } } - -export function useFetchPromise<T>(fetch: Promise<T>) { - const [data, setData] = useState<T | undefined>() - const [error, setError] = useState<Error | undefined>() - const [loading, setLoading] = useState(true) - - useEffect(() => { - const complete = async () => { - try { - setData(await fetch) - } catch (error) { - setError(error as Error) - } finally { - setLoading(false) - } - } - complete() - }, [fetch, setData, setError, setLoading]) - - return { data, error, loading } -} -\ No newline at end of file diff --git a/src/components/page/EventsPage/EventsPage.tsx b/src/components/page/EventsPage/EventsPage.tsx @@ -1,51 +1,62 @@ "use client" -import { Checkbox, ComboBox, Select, Progress, Alert, Form, FormDataMap, Button } from '../../headless' -import { useFetchPromise, useNumberFormatter } from '../../hooks' +import { Checkbox, ComboBox, Select, Loading, Alert, Form, FormDataMap, Button } from '../../headless' +import { useNumberFormatter } from '../../hooks' import { SHEvent } from './useSHEvents' import { FilteredSHEventsInput, useFilteredSHEvents } from './useFilteredSHEvents' import { invariant, isSortType, SORT_TYPES } from '../../../lib' -import { useMemo } from 'react' +import { Suspense, use, useTransition } from 'react' +import { eventsAction } from './eventsAction' -export function EventsPage({ loadedEvents }: { loadedEvents: Promise<SHEvent[]> }) { - const { data: unfilteredEvents = [], error, loading } = useFetchPromise(loadedEvents) - const { events, filterSHEvents } = useFilteredSHEvents(unfilteredEvents) - const priceFormatter = useNumberFormatter({ - style: 'currency', - currency: 'USD', - maximumFractionDigits: 0 - }) +type EventsProps = { + events: Promise<SHEvent[]> + cities: Promise<string[]> +} + +export function EventsPage({ events, cities }: EventsProps) { + const [isPending, startTransition] = useTransition() + // const { events, filterSHEvents } = useFilteredSHEvents(loadedEvents) - const cities = useMemo(() => - [...new Set(unfilteredEvents.map(({ city }) => city))], [unfilteredEvents]) + return ( + <Alert.Context> + <Alert /> + <Form aria-label='Filter Events' action={eventsAction} validator={validator}> + <ComboBox label="City" name="city" options={[]} /> + <Select label="Sort by price" name="priceSort" options={SORT_TYPES} /> + <Checkbox label="Find Cheapest" name="cheapest" /> + <Button type="submit" disabled={false}>Search</Button> + <Form.Error>{(error) => error}</Form.Error> + </Form> + <Alert.ErrorBoundary fallback="Failed to get list of events"> + <Suspense fallback={<Loading />}> + <EventList events={events} /> + </Suspense> + </Alert.ErrorBoundary> + </Alert.Context> + ) +} + +type EventListProps = { + events: Promise<SHEvent[]> +} + +function EventList({ events }: EventListProps) { + const priceFormatter = useNumberFormatter({ + style: 'currency', + currency: 'USD', + maximumFractionDigits: 0 + }) - return ( - <> - <Alert error={error} /> - <Form aria-label='Filter Events' onSubmit={filterSHEvents} validator={validator}> - <ComboBox label="City" name="city" options={cities} /> - <Select label="Sort by price" name="priceSort" options={SORT_TYPES} /> - <Checkbox label="Find Cheapest" name="cheapest" /> - <Button type="submit" disabled={loading}>Search</Button> - <Form.Error>{(error) => error}</Form.Error> - </Form> - <Progress loading={loading}> - <Progress.Bar> - <Progress.Label>Loading events...</Progress.Label> - </Progress.Bar> - <Progress.Content> - <ul> - {events.map(({ id, city, price }) => ( - <li key={id}> - <div>City: {city}</div> - <div>Price: {priceFormatter.format(price)}</div> - </li> - ))} - </ul> - </Progress.Content> - </Progress> - </> - ) + return ( + <ul> + {use(events).map(({ id, city, price }) => ( + <li key={id}> + <div>City: {city}</div> + <div>Price: {priceFormatter.format(price)}</div> + </li> + ))} + </ul> + ) } diff --git a/src/components/page/EventsPage/eventsAction.ts b/src/components/page/EventsPage/eventsAction.ts @@ -0,0 +1,5 @@ +'use server' + +export const eventsAction = async (formData: FormData) => { + console.log('HELLO FOOBAR', Object.fromEntries(formData)) +} +\ No newline at end of file diff --git a/src/components/page/EventsPage/useFilteredSHEvents.ts b/src/components/page/EventsPage/useFilteredSHEvents.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react" +import { use, useEffect, useState } from "react" import { asArray, filterByProp, minByProp, pipe, sortByProp, SortType } from "../../../lib" import { SHEvent } from "./useSHEvents" @@ -18,8 +18,9 @@ const filterCheapest = (cheapest: FilteredSHEventsInput['cheapest']) => (events: SHEvent[]) => cheapest ? minByProp('price', events) : events -export function useFilteredSHEvents(initialEvents: SHEvent[]) { - const [events, setEvents] = useState<SHEvent[]>(initialEvents) +export function useFilteredSHEvents(initialEventsPromise: Promise<SHEvent[]>) { + const initialEvents = use(initialEventsPromise) + const [events, setEvents] = useState<SHEvent[]>() useEffect(() => { setEvents(initialEvents) diff --git a/src/lib/objects.ts b/src/lib/objects.ts @@ -11,6 +11,10 @@ export function isSortType(value: string | undefined | null): value is SortType return value === "ascending" || value === "descending" } +export function isPromise<T>(value: unknown): value is Promise<T> { + return value instanceof Promise +} + export function asArray<T>(value: T | T[] | undefined | null): T[] { return Array.isArray(value) ? value : value !== undefined && value !== null ? [value] : []