… in a Zone … where we can get like a lot of the benefits of RSCs without RSCs …
… you don't have to have server components to have the same benefits that server components give …
Updated for SolidStart v0.5.10 (new beta, first beta version). The app is a port of the December 2020 React Server Components Demo (LICENSE; no pg fork, Data Fetching with React Server Components) but here it's just a basic client side routing implementation. It doesn't use a database but stores the notes via the Unstorage Node.js Filesystem (Lite) driver . This app is not intended to be deployed but simply serves as an experimental platform.
The longer term goal is to eventually leverage island routing to maximum effect once it's more stable and documented (nksaraf already demonstrated that capability (live demo) with a non-standard branch of SolidStart).
The original's demo routing is managed by inside a single context (route.js
) managing the following data:
- A content cache
location
consisting of:selectedId
isEditing
searchText
This “triple” is used as a key to cache server content for that location
.
The location
is exposed in the URL as the encoded URI component value of the location
search parameter; e.g.:
const location = { selectedId: 2, isEditing: false, searchText: '' };
const searchParams = new URLSearchParams([
['location', JSON.stringify(location)],
]);
const base = 'http://localhost:4000/react';
console.log(`${base}?${searchParams.toString()}`);
// "http://localhost:4000/react?location=%7B%22selectedId%22%3A2%2C%22isEditing%22%3Afalse%2C%22searchText%22%3A%22%22%7D"
console.log(`${base}?location=${encodeURIComponent(JSON.stringify(location))}`);
-
refresh(response)
purges/reinitializes the content cache within a transition; while the next rendering has been initiated with fresh data from the server, the existing UI remains intact, fully capable of interrupting the current render with another state update. -
navigate(location)
updates thelocation
state within a transition. -
The
useMutation
hook sends thepayload
associated withlocation
to theendpoint
then using the response torefresh
the content cache. The hook's state reflects the status of the fetch (isSaving
) and stores the last error.
It needs to be explicitly stated: the RSC demo does not support SSR.
RSCs render ReactNodes
that can either be directly inserted into the client's vDOM or deliver prop values to client side components.
The vDOM diffing process then manipulates the DOM accordingly. At no point is there any HTML that needs to be hydrated; if needed, SSR and hydration is handled by the meta-framework (and its client side code).
The demo only employs CSR; the value proposition of RSCs is that server components have access to server resources while their code is not part of the client bundle, instead the RSC client runtime has to be included in addition to React to support the deserialization of data streamed from RSCs.
Any keys necessary for SSR need to appear in the path. So the new path-based routing becomes:
/?search=
:searchText
i.e.{selectedId: undefined, isEditing: false, searchText?}
/new?search=
:searchText
i.e.{selectedId: undefined, isEditing: true, searchText?}
/notes/
:selectedId
?search=
:searchText
i.e.{selectedId, isEditing: false, searchText?}
/notes/
:selectedId
/edit?search=
:searchText
i.e.{selectedId, isEditing: true, searchText?}
Note that :selectedId
and :searchText
can vary independently. In a typical usage scenario :selectedId
will come from a :searchText
search result but once :selectedId
is referenced in the path, :searchText
is free to change and return a result that does not include :selectedId
.
Consequently the server functions are separate:
getBriefs
: fetches the note briefs that match:searchText
.getNote
: fetches the details of the:selectedId
note.
// file: src/api.ts
import { action, cache, redirect, revalidate } from '@solidjs/router';
// …
import {
deleteNote as deleteNt,
getBriefs as getBf,
getNote as getNt,
upsertNote as upsertNt,
} from './server/api';
// …
import type { NoteBrief, Note } from './types';
// …
const getBriefs = cache<(search: string | undefined) => Promise<NoteBrief[]>>(
async (search: string | undefined) => getBf(search),
NAME_GET_BRIEFS
);
const getNote = cache<(noteId: string) => Promise<Note | undefined>>(
async (noteId: string) => getNt(noteId),
NAME_GET_NOTE
);
// …
export { getBriefs, getNote, editAction };
Both of these functions are wrapped in @solidjs/router
's cache()
. The page is fully server rendered on initial load but all subsequent updates are purely client rendered.
But the router's cache()
tracks the currently loaded :noteId
and :search
keys; so rather than running both getBriefs
and getNote
server fetches, the router will only use the one whose key has actually changed (or both if both have changed).
So only the portion of the page that needs to change is updated on the client for navigate()
even when the path changes.
The search
parameter affects the content of the <nav>
within the layout that is independent from any one Route
component; noteId
on the other hand directly impacts which Route
component is chosen.
// file: src/app.tsx
import { mergeProps, Suspense } from 'solid-js';
import { Route, Router, useSearchParams } from '@solidjs/router';
// …
import Note from './routes/note';
import NoteNew from './routes/note-new';
import NoteNone from './routes/note-none';
import NotFound from './routes/not-found';
// …
import type { ParentProps } from 'solid-js';
import type { RouteSectionProps } from '@solidjs/router';
// …
type NotePropsMerge = [{ edit: boolean }, RouteSectionProps];
export default function App() {
const Edit = (props: RouteSectionProps) =>
Note(mergeProps<NotePropsMerge>({ edit: true }, props));
const Display = (props: RouteSectionProps) =>
Note(mergeProps<NotePropsMerge>({ edit: false }, props));
return (
<Router root={Layout}>
<Route path="/new" component={NoteNew} />
<Route path="/notes/:noteId/edit" component={Edit} />
<Route path="/notes/:noteId" component={Display} />
<Route path="/" component={NoteNone} />
<Route path="*404" component={NotFound} />
</Router>
);
}
Aside:
// file: solid-router/src/types.ts
type Params = Record<string, string>;
interface Path {
pathname: string;
search: string;
hash: string;
}
interface Location<S = unknown> extends Path {
query: Params;
state: Readonly<Partial<S>> | null;
key: string;
}
interface RouteSectionProps<T = unknown> {
params: Params;
location: Location;
data?: T;
children?: JSX.Element;
}
Layout (“App Shell”)
The orignal demo's layout is found in App.js
(server component) which here maps to app.tsx
as
search-field.tsx
holds the text to match against the titles of the existing notes.edit-bottom.tsx
triggers the opening ofnote-new.tsx
in thechildren
areabrief-list.tsx
displays all the briefs from notes where the title matches the contentssearch-field
. Clicking a brief opens the matchingnote.tsx
inchildren
.brief-list
serves as the application's navigation.note-none.tsx
appears initially withinchildren
.not-found.tsx
appears withinchildren
for malformed URLs.
// file: src/app.tsx
// …
import { Route, Router, useSearchParams } from '@solidjs/router';
import { MetaProvider } from '@solidjs/meta';
import { EditButton } from './components/edit-button';
import { SearchField } from './components/search-field';
// …
import type { ParentProps } from 'solid-js';
// …
import type { SearchParams } from './route-path';
function Layout(props: ParentProps) {
const [searchParams] = useSearchParams<SearchParams>();
return (
<MetaProvider>
<main class="c-main">
<section class="c-sidebar c-main__column">
<section class="c-sidebar__header">
<img
class="c-logo"
src="/logo.svg"
width="22px"
height="20px"
alt=""
role="presentation"
/>
<strong>Solid Notes</strong>
</section>
<section class="c-sidebar__menu" role="menubar">
<SearchField />
<EditButton kind={'new'}>New</EditButton>
</section>
<Suspense>
<BriefList searchText={searchParams.search} />
</Suspense>
</section>
<section class="c-note-view c-main__column">
<Suspense>{props.children}</Suspense>
</section>
</main>
</MetaProvider>
);
}
Browsers natively implement a behaviour called paint holding; when the browser navigates to a new page (fetched from the server, i.e. not client rendered) the URL in the address bar will update; the old page's paint is held while the DOM of the new page is rendered in the background. Once the new page is painted the old paint is swapped out for the new paint.
Solid's useTransition
is the primitive used to implement Component/Block-level paint holding. To work it relies on the presence of a <Suspense>
boundary which determines the scope and timing of the “transition”.
While there are unsettled async operations under the Suspense
boundary the transition allows rendering of the new content to progress in the background while the old DOM (fragment) is still visible and active on the browser.
The interaction between Suspense
and transition has consequences for the Suspense
fallback
. The fallback
will only ever show on the first render of the suspense boundary.
At that time there is no “block of paint/DOM” to “hold” as the Suspense
boundary didn't previously exist so the fallback
is rendered.
After that transitions take over for as long as the Suspense
boundary exists and the fallback
will never be seen again when new unsettled async operations occur under the Suspense
boundary.
Consequently placement of the Suspense
boundary is crucial for consistent UX. Placing the Suspense
boundary at the root of a component will cause the fallback
to be displayed when it's rendered but transitions take over for subsequent async operations which may seem inconsistent from the UX point of view.
Placing the Suspense
boundary around the “slot” where various components may alternately appear is often the right choice because then transitions between components work as expected, the previous component paint is held while the new component renders in the background.
If the Suspense
fallback
needs to appear whenever an unsettled async operation occurs under the boundary then useTransition
or packages that leverage it like @solidjs/router
cannot be used.
For the signal exposed by useIsRouting()
to work as expected:
- stable, top-level
Suspense
boundaries need to exist inside theroot
layout - unsettled async operations have to be allowed to propagate all the way to the top-level
Suspense
boundaries in theroot
layout. TheisRouting
signal will switch tofalse
once everything settles in the top-level boundary; at that time there can still be unsettled operations that were intercepted by nestedSuspense
boundaries.
This adds up to very different rendering behaviour compared to what is implememented by the RSC demo.
React's transition mechanism does not override the Suspense
fallback.
It renders the new content in the background (the “transition” part) but it also replaces the old content with the Suspense
fallback
.
In cases where a transition can complete in under 1 (or 2) seconds, intermittent “skeleton” screens are judged to provide worse UX than simply holding paint.
The demo makes use of component skeletons (NoteListSkeleton.js
, NoteSkeleton.js
).
Given the use of SSR and the browser's own paint holding behaviour, it turns out that the top-level Suspense
boundaries don't even need fallbacks! The server doesn't send the response until the initial render is complete (i.e. all async operations have settled) so the initial content is already present in the server's HTML; therefore all subsequent client side route renders are governed by transitions.
In an alternate, island architecture the layout could be immediately SSR rendered with skeleton islands and sent back to the browser while the island content is streamed in later once it is ready.
Finally the search-field
doubles as an app-wide spinner for those occasions where transitions don't occur quickly enough.
The spinner is driven by the isRouting
signal; to make it as accurate as possible, only two Suspense
boundaries exist within the entire application, both within the top level root
component; one enveloping the route component children, the other around the brief-list
.
In the orignal demo Note.js
(a server component) plays the role of the router based on the { selectedId, isEditing, searchText }
location value.
As indicated earlier in this implementation we have the following route-to-component mapping:
/
➡note-none.tsx
/new
➡note-new.tsx
/notes/:noteId
➡note.tsx
/notes/:noteId/edit
➡note.tsx
The original Note.js
features both rendering and server side data access (via server/api.server.js
); here the server side functionality is contained separately in getNote()
instead.
During build time SolidStart generates the necessary RPC code for the implied API; during run time its SSR generates the full HTML, adding client side code for hydration and interactivity (which may result in client side rendering later on but can be reduced to a minimum with islands in the future).
This is just the standard 404 Not Found content.
// file: src/routes/not-found.tsx
import { Title } from '@solidjs/meta';
import { HttpStatusCode } from '@solidjs/start';
import { makeTitle } from '../route-path';
function NotFound() {
return (
<div class="c-not-found">
<Title>{makeTitle('Not Found')}</Title>
<HttpStatusCode code={404} />
<h1>Page Not Found</h1>
<p class="c-info-learn">
Visit{' '}
<a href="https://start.solidjs.com" target="_blank">
start.solidjs.com
</a>{' '}
to learn how to build SolidStart apps.
</p>
</div>
);
}
export { NotFound };
Placeholder content until a :noteId
is selected.
// file: src/routes/note-none.tsx
import { Title } from '@solidjs/meta';
import { makeTitle } from '../route-path';
function NoteNone() {
return (
<>
<Title>{makeTitle()}</Title>
<div class="c-note-none">
<span>Click a note on the left to view something! 🥺</span>
</div>
</>
);
}
export { NoteNone };
Initializes the note-editor
to create a new note.
// file: src/routes/note-new.tsx
import { Title } from '@solidjs/meta';
import { makeTitle } from '../route-path';
import { NoteEdit } from '../components/note-edit';
function NoteNew() {
return (
<>
<Title>{makeTitle('New Note')}</Title>
<NoteEdit noteId={undefined} initialTitle={'Untitled'} initialBody={''} />
</>
);
}
export { NoteNew };
The note
route component is used to display the selected note (/notes/:noteid
) or to place the selected note in edit mode (/notes/:noteId/edit
).
In edit mode it simply uses the note-edit
component, otherwise the internal NoteDisplay
component is used.
Both presentations, NoteDisplay
and NoteEdit
, operate under the Suspense
boundary of the layout component.
To take full advantage of the transition's concurrent rendering (while paint holiding) both have to be able to render the structural DOM even before getNote()
settles to deliver the data to be then placed into the DOM.
That is why NoteDisplay
is passed the seemingly redundant noteId
separately; it is available before the note
prop which will be initially undefined
; similarly for note-edit
the noteId
prop is available immediately while both the title
and body
start out undefined
to resolve later to their respective strings.
// file: src/routes/note.tsx
import { onMount, Show } from 'solid-js';
import { isServer, NoHydration } from 'solid-js/web';
import { createAsync, useNavigate } from '@solidjs/router';
import { Title } from '@solidjs/meta';
import { hrefToHome, makeTitle } from '../route-path';
import { getNote } from '../api';
import { localizeFormat, makeNoteDateFormat } from '../lib/date-time';
import { NoteEdit } from '../components/note-edit';
import { EditButton } from '../components/edit-button';
import { NotePreview } from '../components/note-preview';
import type { Location, Navigator, RouteSectionProps } from '@solidjs/router';
import type { Note } from '../types';
const noteDateFormat = isServer
? makeNoteDateFormat()
: makeNoteDateFormat(Intl.DateTimeFormat().resolvedOptions());
function makeTransformOrNavigate(
location: Location<unknown>,
navigate: Navigator
) {
const toNoteExpanded = ({ id, title, body, updatedAt }: Note) => {
const [updated, updatedISO] = noteDateFormat(updatedAt);
return {
id,
title,
body,
updatedAt,
updatedISO,
updated,
};
};
return function transformOrNavigate(maybeNote: Note | undefined) {
if (maybeNote) return toNoteExpanded(maybeNote);
navigate(hrefToHome(location), { replace: true });
};
}
type NoteExpanded = ReturnType<ReturnType<typeof makeTransformOrNavigate>>;
function NoteDisplay(props: { noteId: string; note: NoteExpanded }) {
// `noteId` is available immediately
// while `note` (containing `id`) needs async to fulfill first
const ofNote = (
propName: 'title' | 'body' | 'updated' | 'updatedISO',
defaultValue = ''
) => props.note?.[propName] ?? defaultValue;
let noteUpdated: HTMLElement | undefined;
onMount(() => {
// After hydration correct the display date/time
// if it deviates from the server generated one
// (a request may carry the locale but not the timezone)
// Also `ref` doesn't work on elements inside a `NoHydration` boundary
localizeFormat(noteDateFormat, noteUpdated);
});
return (
<>
<Title>{makeTitle(props.noteId)}</Title>
<div class="c-note">
<div class="c-note__header">
<h1>{ofNote('title')}</h1>
<div class="c-note__menu" role="menubar">
<small ref={noteUpdated} class="c-note__updated" role="status">
Last updated on{' '}
<NoHydration>
<time dateTime={ofNote('updatedISO')}>{ofNote('updated')}</time>
</NoHydration>
</small>
<EditButton kind={'update'}>Edit</EditButton>
</div>
</div>
<NotePreview body={ofNote('body')} />
</div>
</>
);
}
export type NoteProps = RouteSectionProps & { edit: boolean };
function Note(props: NoteProps) {
const isEdit = () => props.edit;
const noteId = () => props.params.noteId;
const navigate = useNavigate();
const transformOrNavigate = makeTransformOrNavigate(props.location, navigate);
const note = createAsync(() => getNote(noteId()).then(transformOrNavigate), {
deferStream: true,
});
return (
<>
<Title>{makeTitle(isEdit() ? `Edit ${noteId()}` : noteId())}</Title>
<Show
when={isEdit()}
fallback={<NoteDisplay noteId={noteId()} note={note()} />}
>
<NoteEdit
noteId={noteId()}
initialTitle={note()?.title}
initialBody={note()?.body}
/>
</Show>
</>
);
}
export { Note };
Another detail to note is the <NoHydration>
boundary around the <time>
element.
The original demo is overly simplistic as it formats the date/time on the server side without giving any consideration to the client's locale.
Granted, for an extended session it would be possible to capture the client's locale as part of the session/authentication information and use that information to correctly format the date on the server. But given that any URL to one of the routes could be used for first load, the server really has no way of initially knowing the correct locale for the date/time as the initial request will not carry any locale information.
All the server can do is initially format the date/time with reference to an arbitrary locale and then have a client script correct it. To this end a <time>
element is used and the server places the full UTC time in the datetime
attribute.
// file: src/lib/date-time.ts
// …
export type FormatFn = (
epochTimestamp: number
) => [local: string, utcIso: string];
// …
function localizeFormat(
format: FormatFn,
timeAncestor: HTMLElement | undefined
): void {
if (!(timeAncestor instanceof HTMLElement))
throw new Error('Unsuitable ancestor element');
const time = timeAncestor.querySelector('time');
if (!(time instanceof HTMLTimeElement))
throw new Error('Unable to locate time element under specified ancestor');
const current = time.textContent;
if (!current) return;
// i.e. nothing to do (CSR waiting for async content)
const epochTimestamp = Date.parse(time.dateTime);
const [local] = format(epochTimestamp);
if (current !== local) time.textContent = local;
}
export { localizeFormat, makeBriefDateFormat, makeNoteDateFormat };
When the NoteDisplay
component is mounted on the client DOM onMount
applies localizeFormat
to acquire the <time>
element, re-run the (localized) format and replacing the element's textContent
if necessary.
Side note: ref
s cannot exist inside a NoHydration
boundary; this makes it necessary to place the ref
on a hydrated ancestor element and use the DOM API to acquire any elements within the NoHydration
boundary.
transformOrNavigate()
does one of two things based on its maybeNote
argument:
- if
getNote()
resolves to aNote
, an expanded note (includes formattedupdated
(local) andupdatedISO
(UTC, full ISO format) strings) is returned for use by the component JSX. - otherwise the
:noteId
is assumed to be invalid which triggers an immediate navigation to the home route, removing the faulty URL fromhistory
.
Just a <div>
with a CSS based spinner animation which maps the active
prop to a modifier to control the animation (original Spinner.js).
// file: src/components/spinner.tsx
export type Props = {
active: boolean;
};
function Spinner(props: Props) {
return (
<div
class={'c-spinner' + (props.active ? ' c-spinner--active' : '')}
role="progressbar"
aria-busy={props.active}
/>
);
}
export { Spinner };
(Original client component SearchField.js).
The purpose of search-field
is to propagate the input search text to the route URL which consequently triggers all the activities associated with that navigation.
What is interesting in the original is the use of useTransition
:
- the
isPending
value indicates that the triggered activities haven't completed yet. startTransition
marks all state updates triggered synchronously by the passedscope
function as part of the “transition”. More importantly callingstartTransition
before the previous invocation completes interrupts the previous transition in favour of the more recent one.
So:
isPending
controls the “busy” spinner- Each
input
event starts a new transition potentially discarding any render work that may have been started on behalf a previous transition.
Note
SolidJS useTransition
is subtly different.
Most importantly a later transition doesn't interupt the previous one.
Presumably React uses some internal state API to track/control the transition.
SolidJS on the other hand tracks the async updates under the suspense boundary triggered by the scope
function to determine when the transition has completed.
Once the transition completes, everything is ready to synchronously batch update the reactive graph which will then propagate changes to the visible DOM as required.
From the user POV this means that the visible DOM isn't changed (and is still interactive) while the transition is in progess but the pending
signal is typically used to alter the UI in some way to indicate that something is happening.
See also: On Suspense
and useTransition
// file: src/components/search-field.tsx
import { createUniqueId } from 'solid-js';
import { useIsRouting, useSearchParams } from '@solidjs/router';
import { debounce } from '../lib/debounce';
import { Spinner } from './spinner';
import type { SearchParams } from '../route-path';
function updateParams(set: (params: Partial<SearchParams>) => void) {
const setParams = debounce<[Partial<SearchParams>]>(set, 250);
return (
event: InputEvent & {
currentTarget: HTMLInputElement;
target: HTMLInputElement;
}
) => {
setParams({ search: event.currentTarget.value });
event.preventDefault();
};
}
const preventSubmit = (
event: Event & { submitter: HTMLElement } & {
currentTarget: HTMLFormElement;
target: Element;
}
) => event.preventDefault();
function SearchField() {
const searchInputId = createUniqueId();
const isRouting = useIsRouting();
const [searchParams, setSearchParams] = useSearchParams<SearchParams>();
const updateSearch = updateParams(setSearchParams);
return (
<form class="c-search-field" role="search" onSubmit={preventSubmit}>
<label class="u-offscreen" for={searchInputId}>
Search for a note by title
</label>
<input
id={searchInputId}
placeholder="Search"
value={searchParams.search ?? ''}
onInput={updateSearch}
/>
<Spinner active={isRouting()} />
</form>
);
}
export { SearchField };
In solid-start (with solid-router) the transition is managed by the router.
It provides useIsRouting()
which exposes the signal needed to activate the spinner.
The advantage of this approach is that is that the spinner activates whenever there is a route change, not just when the search-field
is responsible for the route change.
To minimize frequent, intermediate route changes the input event listener is debounced (shamelessly lifted from solid-primitives) to delay the route change until there hasn't been a new input for 250ms.
useSearchParams()
exposes access to the route's query string making it possible to initialize search-field
from the URL but also granting it the capability to update the route URL (i.e. to initiate a navigation).
createUniqueId()
is used to create a unique ID to correlate the <label>
to the <input>
.
(Original client component EditButton.js
)
Note
Again the original component wraps its navigation in a transition—with solid-start
/solid-router
that responsibility becomes part of the router's navigation functionality.
The kind
prop determines whether the button navigates to the update note route or the new note route. useLocation()
provides access to the currrent URL which is used as the basis to generate the URL to navigate to.
Navigation is accessed through useNavigate()
.
// file: src/components/edit-button.tsx
import { useLocation, useNavigate } from '@solidjs/router';
import { hrefToNoteUpdate, hrefToNoteNew } from '../route-path';
import type { ParentProps } from 'solid-js';
type Props = ParentProps & {
kind: 'update' | 'new';
};
const classList = (kind: Props['kind']) =>
'c-edit-button' +
(kind === 'new' ? ' js:c-edit-button--new' : ' js:c-edit-button--update');
function EditButton(props: Props) {
const location = useLocation();
const navigate = useNavigate();
const toHref = props.kind === 'new' ? hrefToNoteNew : hrefToNoteUpdate;
return (
<button
class={classList(props.kind)}
onClick={() => navigate(toHref(location))}
disabled={false}
>
{props.children}
</button>
);
}
export { EditButton };
(Related client component: SidebarNoteContent.js
)
The SidebarNoteContent
component in the orignal demo triggers a “flash” animation when it detects the title changing between two renders.
In this implementation the intent was reinterpreted to mean: “flash” when an action saves the Note
to the server.
The app-context
manages a central lastEdit
signal that allows the consumer of the SendLastEditHolder
type (useSendLastEdit()
) to broadcast a LastEdit
value to the consumers of the LastEditHolder
type (useLastEdit()
).
Note
The LastEdit
type is intended to be used as a discriminated union.
// file: src/components/app-context.tsx
// NOTE: Core state is **represented** via the route
// while the application context is the hub for
// ephemeral (extended) state.
// Ephemeral state isn't relevant to SSR.
import {
createContext,
createSignal,
useContext,
type ParentProps,
} from 'solid-js';
import type { LastEdit } from '../types';
// Primitives over features …
const [lastEdit, sendLastEdit] = createSignal<LastEdit | undefined>(undefined, {
equals: false,
});
export type LastEditHolder = {
lastEdit: typeof lastEdit;
};
export type SendLastEditHolder = {
sendLastEdit: typeof sendLastEdit;
};
const context = {
lastEdit,
sendLastEdit,
};
const AppContext = createContext(context);
const AppProvider = (props: ParentProps) => (
<AppContext.Provider value={context}>{props.children}</AppContext.Provider>
);
function useAppContext() {
const ctx = useContext(AppContext);
if (!ctx) throw new Error('AppContext not initialized');
return ctx;
}
const useLastEdit: () => LastEditHolder = useAppContext;
const useSendLastEdit: () => SendLastEditHolder = useAppContext;
export { AppProvider, useLastEdit, useSendLastEdit };
Note
Both useLastEdit
and useSendLastEdit
leverage a peculiarity of TypeScript (i.e. defined return types are not subject to excess property checks); both have exactly the same runtime return type but in each case the defined return type is used to partially mask out “excess” properties at analysis time. There is nothing stopping runtime access but TypeScript will flag access of the “excess” properties at analysis time.
(Original server component NoteList.js
, component SidebarNote.js
, client component SidebarNoteContent.js
)
Disecting the original:
NoteList
is a server component; it accesses the database directly. If thesearchText
doesn't yield any results, it simply renders theReactNodes
for the empty list. If there are results the generatedReactNodes
are a mixture of intrinsic<ul>
,<li>
andReactElements
carrying anote
prop for aSidebarNote
component.SidebarNote
isn't marked with"use client"
implying it will run as a server component; this could be problematic if date/time formatting should actually be done on the client side to display values consistent with the client locale. As given, date/times will always be formatted with the server locale.SidebarNote
transforms thenote
prop tochildren
,id
,title
andexpandedChildren
props forSidebarNoteContent
.SidebarNoteContent
is the client component that (client side) generates the intrinsicReactElement
s based on the props sent from the server. Clicking the content initiates navigation (wrapped in a transition) to the full note preview. Whenever thetitle
changes between re-renders, an effect triggers a CSS animation by adding aflash
class. Component state tracks the expansion status of the content; clicking the toggle<button>
expands and collapses theexpandedChildren
prop which contains the rendered note excerpt/summary.NoteListSkeleton.js
serves as the fallback for the suspense boundary aroundNoteList
.
Within brief-list
functionality exclusive to the server side is accessed via getBriefs
:
// file: src/components/brief-list.tsx
// …
import { getBriefs } from '../api';
// …
// file: src/api.ts
import { action, cache, redirect, revalidate } from '@solidjs/router';
// …
import {
deleteNote as deleteNt,
getBriefs as getBf,
getNote as getNt,
upsertNote as upsertNt,
} from './server/api';
// …
import type { NoteBrief, Note } from './types';
// …
const NAME_GET_BRIEFS = 'briefs';
// …
const getBriefs = cache<(search: string | undefined) => Promise<NoteBrief[]>>(
async (search: string | undefined) => getBf(search),
NAME_GET_BRIEFS
);
// …
Important
src/api.ts
only contains code that runs on the client. It's only the imports from src/server/api.ts
that are accessed remotely.
The code that runs on the server:
// file: src/server/api.ts
'use server';
import {
deleteNote as deleteNoteById,
insertNote,
selectNote,
selectNotesInTitle,
updateNote,
} from './repo';
import { toNote, toNoteBrief, type NotePersist } from './types';
// …
const toBriefs = (notes: NotePersist[]) => notes.map(toNoteBrief);
const getBriefs = (search: string | undefined) =>
selectNotesInTitle(search).then(toBriefs);
// …
The 'use server'
directive instructs the bundler to establish a serialization boundary around the code that allows remote access by client functionality. It is recommended to use the directive at a function scope; here it is used at a module scope as all the functionality in this module exists to back the client side src/api.ts
module.
All the remaining code is client side code which will be server rendered on first load. However without island routing (still experimental) all the rendering JavaScript is still downloaded to the client which SolidJS largely mitigates by always prioritizing small client side bundles.
// file: src/components/brief-list.tsx
// …
type Props = {
searchText: string | undefined;
};
function BriefList(props: Props) {
// … some assembly required …
// …
return (
<nav ref={root}>
<Show
when={briefs().length}
fallback={<EmptyContent search={props.searchText} />}
>
<ul class="c-brief-list">
<For each={briefs()}>
{(brief: NoteBrief) => (
<li>
<Brief
noteId={brief.id}
title={brief.title}
summary={brief.summary}
updatedAt={brief.updatedAt}
active={briefActive(activeInfo(), brief.id)}
flushed={updatedId() === brief.id}
format={format}
/>
</li>
)}
</For>
</ul>
</Show>
</nav>
);
}
From a rendering perspective BriefList
simply creates an unordered list of Brief
s.
The brief excerpt (summary) is stored with the associated note; even though it could be derived on each render, generating it only whenever the note is saved reduces the rendering overhead.
The store accessed through the briefs
accessor drives the the updates:
// file: src/components/brief-list.tsx
// …
function setupBriefStore(currentSearch: () => string | undefined) {
const [briefStore, setBriefs] = createStore<NoteBrief[]>([]);
return [
async function updateBriefStore() {
const next = await getBriefs(currentSearch());
setBriefs(reconcile(next));
return briefStore;
},
{ initialValue: briefStore },
] as const;
}
type Props = {
searchText: string | undefined;
};
function BriefList(props: Props) {
// …
// Note: briefs is a signal carrying a finer grained store
const briefs = createAsync(...setupBriefStore(() => props.searchText));
// …
}
createAsync()
creates an “asynchronous to reactive (i.e. synchronous)” value boundary which is necessary to leverage a cache()
wrapped asynchrounous remote accessor.
createAsync()
only returns a (signal) accessor but given that updates occur by “remote list” lacking any referential stability, the use of a store in combination with reconcile
is indicated.
Fortunately there is a tactic to deal with this apparent impedance mismatch; return the store from the function being wrapped by createAsync()
.
Meanwhile in the wrapped async function reconcile the store with the most recent “remote list” while always returning the reference to the now up-to-date and referentially stable store.
When the store contains no NoteBrief
s, EmptyContent
is shown.
// file: src/components/brief-list.tsx
// …
const EmptyContent = (props: { search: string | undefined }) => (
<div class="c-brief-empty">
{props.search
? `Couldn't find any notes titled "${props.search}"`
: 'No notes created yet!'}{' '}
</div>
);
// …
The details of each NoteBrief
render is delegated to the Brief
component, however three additional props are passed: active
, flushed
, and format
.
format
is created on the brief-list
level and then shared across all brief
s to avoid repeatedly creating the same data/time formatting function.
When rendering on the server, the en-GB
locale and UTC is used; however later on the client side this is replaced with the client's preferred formatting within brief
.
// file: src/components/brief-list.tsx
// …
import { isServer } from 'solid-js/web';
// …
import { makeBriefDateFormat } from '../lib/date-time';
// …
function briefDateFormat() {
if (isServer) {
return makeBriefDateFormat();
} else {
return makeBriefDateFormat(Intl.DateTimeFormat().resolvedOptions());
}
}
// …
type Props = {
searchText: string | undefined;
};
function BriefList(props: Props) {
// …
const format = briefDateFormat();
// …
}
// …
A brief
's active
prop informs it whether or not its associated Note
is currently being displayed or being navigated to.
Given that the route is driven by brief-list
the note ID enters the “pending” state before the route transition.
Once the route transition is complete that note ID enters the “active” state.
The navigation functionality is hoisted up into brief-list
by using DOM event delegation; a Brief
simply communicates which note it represents via its data-note-id
data attribute.
// file: src/components/brief-list.tsx
import { createSignal, createMemo, For, onMount, Show } from 'solid-js';
// …
import {
createAsync,
useLocation,
useNavigate,
useParams,
} from '@solidjs/router';
// …
import { nextToNote } from '../route-path';
// …
import { Brief, type Active } from './brief';
// …
function findNoteId(target: unknown) {
if (
!(
target instanceof HTMLButtonElement &&
target.classList.contains('js:c-brief__open')
)
)
return undefined;
const container = target.closest('div.js\\:c-brief');
return container instanceof HTMLDivElement
? container.dataset.noteId
: undefined;
}
// …
const briefActive = ([id, active]: [string, Active], briefId: string) =>
id === briefId ? active : 0;
type ClickedInfo = {
noteId: string;
pathname: string;
};
type Props = {
searchText: string | undefined;
};
function BriefList(props: Props) {
const params = useParams();
const noteId = () => params.noteId ?? '';
const location = useLocation();
const [clickedInfo, setClickedInfo] = createSignal<ClickedInfo>({
noteId: noteId(),
pathname: location.pathname,
});
const activeInfo = createMemo((): [string, Active] => {
const info = clickedInfo();
if (location.pathname !== info.pathname && info.noteId.length > 0)
return [info.noteId, 1];
const id = noteId();
return id.length > 0 ? [id, 2] : ['', 0];
});
// Highlight clicked brief BEFORE initiating
// navigation to the associated note
const navigate = useNavigate();
const navigateToClicked = (event: MouseEvent) => {
const id = findNoteId(event.target);
if (!id) return;
const next = nextToNote(location, id);
setClickedInfo({ noteId: id, pathname: next.pathname });
navigate(next.href);
};
// …
let root: HTMLElement | undefined;
onMount(() => {
if (root instanceof HTMLElement)
root.addEventListener('click', navigateToClicked);
});
// …
}
// …
The currently active noteId
is delivered via the route parameters.
The noteId
derived signal ensures a string type (empty string instead of undefined
).
The clickedInfo
accessor pair tracks the ID of the last clicked brief together with its destination pathname.
clickedInfo
is updated by the navigateToClicked
event listener that is mounted on the root
element (a <nav>
) of the brief-list
component.
The listener extracts the note ID from the clicked brief
's data-note-id
data attribute and sets the [noteId, pathname]
pair before it navigates to the note.
activeInfo
's derivation function returns a [noteId, state]
pair.
The first element holds the noteId
or an empty string when the second element (state
) is 0
(no note is “pending” or “active”):
1
indicates that navigation tonoteId
is “pending”.2
appears once the navigation tonoteId
completes making it “active”.
When the current location.pathname
doesn't match the clickedInfo
destination pathname while clickedInfo.noteId
is non-empty then that noteId
is “pending” ([noteId, 1]
).
Otherwise a non-empty noteId()
results in a [noteId, 2]
(“active”) value.
['', 0]
indicates that no noteId
is “pending” or “active”.
updatedId
drives the flush
prop on the listed briefs and derives from useLastEdit()
and notesId()
// file: src/components/brief-list.tsx
// …
import {
createAsync,
useLocation,
useNavigate,
useParams,
} from '@solidjs/router';
// …
import { useLastEdit, type LastEditHolder } from './app-context';
// …
function deriveLastUpdateId(
noteId: () => string,
lastEdit: LastEditHolder['lastEdit']
) {
const NO_UPDATED_ID = '';
return () => {
const last = lastEdit();
switch (last?.[0]) {
case undefined:
case 'delete': {
return NO_UPDATED_ID;
}
case 'update': {
return last[1];
}
case 'new': {
// IDs are server assigned so get it form the URL
// of the currently open note.
const id = noteId();
return typeof id === 'string' && id.length > 0 ? id : NO_UPDATED_ID;
}
default:
return NO_UPDATED_ID;
}
};
}
// …
type Props = {
searchText: string | undefined;
};
function BriefList(props: Props) {
const params = useParams();
const noteId = () => params.noteId ?? '';
// …
// updatedId
const { lastEdit } = useLastEdit();
const updatedId = createMemo(deriveLastUpdateId(noteId, lastEdit));
// …
}
// …
In this instance a lastEdit()
value of ['delete', noteId]
doesn't communicate anything of value as a brief
of a deleted Note
will never appear on the brief-list
.
['update', noteId]
is the simplest case as the noteId
can be obtained directly from the pair.
['new']
will run twice.
Initially when the new Note
is sent to the server params.noteId
won't have anything of value.
But because the reactive value was referenced the derivation will run again once params.noteId
has been set to the new Note
's ID due to the navigaton that was triggered by saving the new Note
.
So in the end updatedId()
will hold the ID of the new Note
(after briefly being an empty string).
Within (reactive) JSX updatedId() === brief.id
indicates to a brief on the list whether it has been recently flushed
; on the leading edge of the property being set the Brief
can initiate a CSS “flash” animation.
(Original server component SidebarNote.js
, client component SidebarNoteContent.js
)
The orginal SidebarNoteContent.js
):
- Shows the
Note
's title and updated date. - Features a toggle to expand/hide the summary/extract.
- Navigates to the full
Note
when clicked. - Triggers a “flash” animation when the title changes between renders.
This particular brief implementation does not use component state to drive the visibility of the summary/excerpt.
The DOM is fully rendered but the state of the c-brief__summary-state
checkbox controls the opacity and position of c-brief__summary
with CSS rulesets.
// file: src/components/brief.tsx
import { createUniqueId, onMount, Show } from 'solid-js';
import { NoHydration } from 'solid-js/web';
import { localizeFormat, type FormatFn } from '../lib/date-time';
export type Active = 0 | 1 | 2;
type Props = {
noteId: string;
title: string;
summary: string;
updatedAt: number;
active: Active;
flushed: boolean;
format: FormatFn;
};
const CLASSNAME_FLASH = ' js:c-brief--flash';
const activeModifier = ['', ' c-brief--pending', ' c-brief--active'];
const classListBrief = (flushed: boolean) =>
'js:c-brief c-brief ' + (flushed ? CLASSNAME_FLASH : '');
const classListOpen = (active: Active) =>
'js:c-brief__open c-brief__open' + activeModifier[active];
function Brief(props: Props) {
let brief: HTMLDivElement | undefined;
function removeFlash(event: AnimationEvent) {
if (event.animationName === 'flash')
brief?.classList.remove(CLASSNAME_FLASH);
}
let header: HTMLElement | undefined;
const toggleId = createUniqueId();
// non-reactive
const [updatedAt, updatedISO] = props.format(props.updatedAt);
onMount(() => {
// After hydration correct the display date/time
// if it deviates from the server generated one
// (a request may carry the locale but not the timezone)
// Also `ref` doesn't work on elements inside `NoHydration`
localizeFormat(props.format, header);
});
return (
<div
ref={brief}
class={classListBrief(props.flushed)}
onAnimationEnd={removeFlash}
data-note-id={props.noteId}
>
<header ref={header}>
<strong>{props.title}</strong>
<NoHydration>
<time datetime={updatedISO}>{updatedAt}</time>
</NoHydration>
</header>
<input id={toggleId} type="checkbox" class="c-brief__summary-state" />
<button class={classListOpen(props.active)}>Open note for preview</button>
<label for={toggleId} class="c-brief__summary-toggle">
<svg
viewBox="0 0 512 512"
aria-hidden="true"
fill="currentColor"
width="1em"
height="1em"
>
<path d="M60 99.333l196 196 196-196 60 60-256 256-256-256z"></path>
</svg>
</label>
<p class="c-brief__summary">
<Show
when={props.summary}
fallback={<span class="c-brief__no-content">(No Content)</span>}
>
{props.summary}
</Show>
</p>
</div>
);
}
export { Brief };
classListBrief
assembles the class names for the Brief
's container <div>
.
The flash animation is triggered by the Brief
's flush
prop.
The removeFlash
listener is added to the element's animationend
event to remove the triggering class name once the animation completes.
classListOpen
assembles the class names for the "open note" button. The button itself is the Brief
's background which communicates a “pending” or “active” state. The active
argument selects the appropriate “pending” or “active” modifier.
The updatedAt
time is adjusted to the client locale in a similar fashion as discussed with the note
component.
(Original components NotePreview.js
, TextWithMarkdown.js
.) The note-preview
takes the passed body
prop containing markdown text and transforms it to HTML, placing it into the <div>
container (innerHTML
);
// file: src/components/note-preview.tsx
import { mdToHtml } from '../lib/md-to-html';
type Props = {
body: string;
};
const NotePreview = (props: Props) => (
<div class="c-note-preview">
<div
class="o-from-markdown"
innerHTML={props.body ? mdToHtml(props.body) : ''}
/>
</div>
);
export { NotePreview };
Note
Server side HTML sanitation is performed by sanitize-html
which doesn't need DOM support.
Unfortunately when used on the client side with Vite, the PostCSS dependency causes issues.
On the client side DOMPurify works but would require (slow) JSDOM on the server side.
This creates the requirement of using sanitize-html
on the server side for SSR while keeping it out of the client side bundle (where DOMPurify
is used).
Therefore components using mdToHtml
import src/lib/md-to-html
:
// file: src/lib/md-to-html.ts
import { isServer } from 'solid-js/web';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
let toHtml: ((mdText: string) => string) | undefined;
function mdToHtml(mdText: string) {
if (toHtml) return toHtml(mdText);
// entry-server.tsx attaches mdToHtml based on sanitizeHtml
// which doesn't require DOM for SSR.
toHtml = isServer
? globalThis.ssrSupport.mdToHtml
: (mdArg: string) =>
DOMPurify.sanitize(marked.parse(mdArg, { async: false }) as string);
// Vite pulls in node APIs due to sanitizeHtml's optional postCSS support
// On client side use DOMPurify instead but don't import HERE to keep `sanitizeHtml`
// out of the client bundle
if (!toHtml) throw new Error('Late binding of `mdToHtml` failed');
return mdToHtml(mdText);
}
export { mdToHtml };
While DOMPurify
is unconditionally imported, on the server globalThis.ssrSupport.mdToHtml
is used instead.
This run time dependency is imported and set only by src/entry-server.tsx
:
// file: src/entry-server.tsx
import { createHandler, StartServer } from '@solidjs/start/server';
// See: src/lib/md-to-html.ts
import { mdToHtml } from './server/md-to-html';
declare global {
// eslint-disable-next-line no-var
var ssrSupport: {
mdToHtml: (mdText: string) => string;
};
}
globalThis.ssrSupport = {
mdToHtml,
};
export default createHandler(() => (
// …
));
(Original client component NoteEditor.js
.) The note-edit
component is used create a new note and modify an existing note.
The noteId
prop is undefined
for a new note and a non-empty string with the note's ID for an existing note.
A form contains a text input to allow editing of the note time and a textarea for the note body.
It also contains some hidden inputs containing supplementary information for the edit action
:
id
: contains the note ID for an existiing note or empty string for a new notefrom
: the current route URL. The route navigated to derives from this value,intent
:new
: create a new noteupdate
: modify existing note identified byid
delete
: delete existing note identified byid
The preview portion consists of a menu, label, preview note title and note-preview
handling the note body.
The menu contains a “Done” button and for update
edits a “Delete” button.
// file: src/components/note-edit.tsx
// …
import { toRootpath } from '../route-path';
import { editAction } from '../api';
// …
import { NotePreview } from './note-preview';
type Props = {
noteId: string | undefined;
initialTitle: string | undefined;
initialBody: string | undefined;
};
// …
function NoteEdit(props: Props) {
// …
return (
<div class="c-note-edit">
<form
ref={noteForm}
class="c-note-edit__form"
method="post"
action={editAction}
>
<input type="hidden" name="id" value={props.noteId ?? ''} />
<input type="hidden" name="from" value={toRootpath(location)} />
<input type="hidden" name="intent" value={intent()} />
<label class="u-offscreen" for="note-edit__title">
Enter a title for your note
</label>
<input
id="note-edit__title"
type="text"
name="title"
value={title()}
onInput={titleListener}
/>
<label class="u-offscreen" for="note-edit__body">
Enter the body for your note
</label>
<textarea
id="note-edit__body"
name="body"
value={body()}
onInput={bodyListener}
/>
</form>
<div class="c-note-edit__preview">
<div class="c-note-edit__menu" role="menubar">
<button
class="c-note-edit__done"
role="menuitem"
disabled={busy()}
onClick={saveNote}
>
<img
src="/checkmark.svg"
width="14px"
height="10px"
alt=""
role="presentation"
/>
Done
</button>
<Show when={isUpdate()}>
<button
class="c-note-edit__delete"
role="menuitem"
disabled={busy()}
onClick={deleteNote}
>
<img
src="/cross.svg"
width="10px"
height="10px"
alt=""
role="presentation"
/>
Delete
</button>
</Show>
</div>
<div class="c-note-edit__label-preview" role="status">
Preview
</div>
<h1 class="c-note-edit__note-title">{title()}</h1>
<NotePreview body={body()} />
</div>
</div>
);
}
export { NoteEdit };
isUpdate
is implemented as a derived signal based on the incoming noteId
prop.The note-new
route component supplies an undefined
noteId
while note
supplies it from the route parameters.
For the lifetime of the component noteId
(and by extension isUpdate
) isn't expected to change despite the fact that all props are reactive.
The intent
Accessor is initialized isUpdate
which the help of isUpdate
setting it to either EditIntent
type new
or update
.
The signal's setter creates the opportunity to change it to delete
instead which is used by the delete capability.
The busy
signal is used to disable the action controls once an action has been intiated.
Both the initialTitle
and initialBody
props can be undefined
in the note
route component use case.
The goal is to be able to set up the full fragment of the component DOM in the background (hidden by a router initiated transition) while the data to be placed into the fragment hasn't arrived yet; in other words both initialTitle
and initialBody
are still unknown.
There are 3 phases to the final title and body:
- Initially the initial value is (potentially) unknown; the incoming prop being
undefined
indicates this state. Here the final value should appear as an empty string. This is an arbitrary decision as the component content at this point is still hidden by the transition. - When the transition completes the prop will change to the true initial value. Before any edits take place the known initial value is the final value.
- Once an edit takes place that value (and every subsequent edit) becomes the final value.
This is expressed by:
const [updatedBody, setBody] = createSignal<TextUpdate>(undefined) as TextUpdatePair ;
const body = () => updatedBody() ?? props.initialBody ?? '';
// file: src/components/note-edit.tsx
import { createSignal, Show } from 'solid-js';
// …
import { toLastEdit, type EditIntent} from '../types';
// …
type Props = {
noteId: string | undefined;
initialTitle: string | undefined;
initialBody: string | undefined;
};
// remove `undefined` from setter argument union type
type UpdatePair<T> = [updated: () => T | undefined, setter: (updated: Exclude<T, undefined>) => void];
type TextUpdate = string | undefined;
type TextUpdatePair = UpdatePair<TextUpdate>;
const maybeNoteId = (maybe: string | undefined) =>
typeof maybe === 'string' && maybe.length > 0 ? maybe : undefined;
function NoteEdit(props: Props) {
const isUpdate = () => Boolean(maybeNoteId(props.noteId));
const [intent, setIntent] = createSignal<EditIntent>(
isUpdate() ? 'update' : 'new'
);
const [busy, setBusy] = createSignal(false);
const [updatedTitle, setTitle] = createSignal<TextUpdate>(undefined) as TextUpdatePair;
const [updatedBody, setBody] = createSignal<TextUpdate>(undefined) as TextUpdatePair ;
// follow reactive prop while no edit has taken place
const title = () => updatedTitle() ?? props.initialTitle ?? '';
const body = () => updatedBody() ?? props.initialBody ?? '';
// …
}
Both titleListener
and bodyListener
update their respective “updated” signals whenever the input control fires.
The updated values synchronously propagate via derived signals; title()
which drives the heading element and body()
which propagates into the note-preview
component.
// file: src/components/note-edit.tsx
// …
function NoteEdit(props: Props) {
// …
const titleListener = (
e: InputEvent & {
currentTarget: HTMLInputElement;
target: HTMLInputElement;
}
) => {
e.stopPropagation();
setTitle(e.currentTarget.value);
};
const bodyListener = (
e: InputEvent & {
currentTarget: HTMLTextAreaElement;
target: HTMLTextAreaElement;
}
) => {
e.stopPropagation();
setBody(e.currentTarget.value);
};
// …
}
The click handlers for the “Done” (saveNote
) and “Delete” (deleteNote
) button are similar.
After verifying the preconditions (a set noteForm
element reference and the noteId
for existing notes) the busy()
signal is turned on (disabling the listening elements) and the global lastEdit
signal is used to communicate the action about to be requested to the rest of the application (to brief-list
specifically).
Finally requestSubmit()
initiates the edit action.
The values inside the form determine the nature of the action:
id
: hidden value which followsprops.noteId
but will be an empty string for anew
note.from
: hidden value which followspathname
+search
derived fromlocation
. The action uses it to formulate the following redirect URL.intent
: hidden value which follows theintent()
signal. Initially set tonew
whenprops.noteId
isundefined
or set toupdate
otherwise. Forced todelete
(fromupdate
) by thedeleteNote
handler. In effect acts as the RPC discriminator; selects which action to perform.title
: the latest edit of the note title directly from the form (title()
signal exists to drive thenote-preview
prop).note
: the latest edit of the note body directly from the form (body()
signal exists to drive thenote-preview
prop).
// file: src/components/note-edit.tsx
// …
import { useLocation } from '@solidjs/router';
import { toLastEdit, type EditIntent} from '../types';
// …
import { editAction } from '../api';
import { useSendLastEdit } from './app-context';
// …
function NoteEdit(props: Props) {
// …
const location = useLocation();
const { sendLastEdit: send } = useSendLastEdit();
let noteForm: HTMLFormElement | undefined;
// clear app-context
send(undefined);
const saveNote = (
e: MouseEvent & { currentTarget: HTMLButtonElement; target: Element }
) => {
if (!noteForm) return;
setBusy(true);
e.stopPropagation();
// `intent()` informs editAction whether to
// perform `insert` (new) or `update` (edit)
//
// inform the rest of the application
// of impending `new` or `edit`
send(toLastEdit(intent(), maybeNoteId(props.noteId)));
noteForm.requestSubmit();
};
const deleteNote = (
e: MouseEvent & { currentTarget: HTMLButtonElement; target: Element }
) => {
const id = maybeNoteId(props.noteId);
if (!(noteForm && id)) return;
setBusy(true);
e.stopPropagation();
// inform editAction to perform delete
setIntent('delete');
// inform the rest of the application
// of impending delete
send(toLastEdit(intent(), id));
// submit editAction
noteForm.requestSubmit();
};
// …
}
editAction
is bound to the action
prop of the form's JSX.
editAction
still executes on the client side; it's upsertNote
and deleteNote
which are remotely marshalled over solid-start's RPC mechanism.
The form's data is accessed via the FormData
mechanism.
The makes it necessary validate/reconstitute the data from the FormData
's string based key/value pairs; in particular: id
, from
, intent
, title
and body
.
new
and update
are directed to upsertNote
.
Typically a note with its ID is returned in which case the redirect will lead to the note URL; in case of an error from the server the redirect will simply go to the home URL.
The briefs
cache needs to be revalidate
d as the edit may have modified aspects of a note that impact parts of a brief that is already part of the current search result or created a new note that should be part of the current briefs search result.
delete
directs to deleteNote
.
The briefs
cache needs to be revalidiate
d given that the deleted note likely appeared as a brief on the list.
Finally the app is redirected to the home route while leaving the search parameters intact.
// file: src/api.ts
import { action, cache, redirect, revalidate } from '@solidjs/router';
import {
searchFromRootpath,
rootpathToHome,
rootpathWithNote,
} from './route-path';
import {
deleteNote as deleteNt,
getBriefs as getBf,
getNote as getNt,
upsertNote as upsertNt,
} from './server/api';
import { isEditIntent } from './types';
// …
const NAME_GET_BRIEFS = 'briefs';
const NAME_GET_NOTE = 'note';
// …
const editAction = action(async (data: FormData) => {
const intent = data.get('intent');
if (typeof intent !== 'string' || !isEditIntent(intent))
throw new Error(`Malformed edit-action. "intent" param: :${intent}`);
const from = data.get('from');
if (typeof from !== 'string' || from.length < 1)
throw new Error(`Malformed edit-action. "from" param: :${from}`);
const search = searchFromRootpath(from);
const noteId = data.get('id') ?? '';
const title = data.get('title') ?? '';
const body = data.get('body') ?? '';
if (
typeof noteId !== 'string' ||
typeof title !== 'string' ||
typeof body !== 'string'
)
throw new Error('Malformed edit-action. note params');
const id = noteId.length > 0 ? noteId : undefined;
if (intent === 'new' || intent === 'update') {
const note = await upsertNt(title, body, id);
// navigate to `/notes/:id` if note was returned
// otherwise navigate to `/`
const path = note
? rootpathWithNote(note.id, search)
: rootpathToHome(search);
// - A new note may now need to appear in the briefs
// - An existing note's title (or excerpt) could have changed
// i.e. briefs need to be reloaded even if the
// search param didn't change
revalidate(NAME_GET_BRIEFS, true);
throw redirect(path);
}
if (intent === 'delete') {
if (!id) throw new Error('edit-action: cannot delete without ID');
await deleteNt(id);
// - after delete note may need to be removed from briefs (i.e. revalidate)
revalidate(NAME_GET_BRIEFS, true);
// - navigate to the home page
throw redirect(rootpathToHome(search));
}
}, NAME_EDIT_ACTION);
// …