Repository navigation
v0.5.0
TLDR: links navigate without reloading the page, saved storage() state
renders on the server, pages can set their HTTP status and read the request,
onIdle loads background code after the page settles, and streamed content is
picked up as it is instead of being rendered twice. Most apps need no changes.
Markless now packs your app by default, navigates without reloading the page,
and responds to clicks faster, with fewer requests and less code running at
load.
Held back: @markless/ui, @markless/icons, @markless/ui-tools
These three packages are not part of 0.5.0 on npm. @markless/ui 0.5.0 needs
@markless/icons and @markless/ui-tools, and those two names have never been
published, so the release workflow cannot publish them yet (npm only lets a
workflow publish a name that already exists). They ship in a later release once
the names are claimed. @markless/ui 0.4.0 stays the latest on npm. Nothing in
the framework packages or the create-markless starters depends on them.
Highlights
- Client-side navigation works. Router links and plain
<a>links to your
own pages swap in the next page without a reload (details below). onIdle.onIdle={handler}runs once after the page has loaded and the
browser is idle. Use it to start third-party or background code without
slowing the first paint or the first click.storage()renders on the server. Astorage()value is now also saved
in a cookie namedmarkless-<key>, so the server renders the saved value in
the first response. A saved tab or theme no longer flashes its default.
Pages that readstorage()are sentCache-Control: privateand
Vary: Cookie.- Pages can read the request and set the response. Every page gets an
httpprop withrequest,url,localsandresponse. Set
http.response.statusor headers before the page starts streaming, for
example a 404 page.httpis server-only: reading it from browser code fails
the build withMARKLESS_SERVER_ONLY_PROP_BROWSER_READ. - Streamed content is adopted, not re-rendered. When an
@trysection
streams in from the server, the browser takes over the HTML it received
instead of building it again, and a click made while it streams runs right
away. - Resume survives third-party scripts. If a script changes the page while
it loads (a search button filling itself in, a GitHub star button swapping
its link), the rest of the page still wakes up correctly. Only an element the
script replaced is affected, and it reports an error instead of breaking the
page. - The link script is a cached file. The router's link code is served once
as/_markless/links.<hash>.jswith a long cache lifetime, instead of inline
in every page. Each later page view is about 2.7 kB smaller. - Many fixes found by moving real apps to Markless: htmx.org, alpinejs.dev,
the Next.js dashboard example, Remix notes, Conduit and a Docusaurus site.
See Compiler and Fixes below. create-marklessasks which editor you use (VS Code, Cursor, Windsurf,
Zed, or other) and writes only that editor's settings. Pass--editorto
skip the question. It also checks your Node.js version before asking
anything and tells you which version to install, and it works with Bun and
Deno. The next steps now point at the real dev port, 3000.
Fixes from migrating real apps
- A
Linkwithouthrefbuilds, so links whose children come from data work. - MDX pages render lowercase JSX tags and
{/* comments */}. document.tsrxrenders child components and@for,@ifand@switch,
and the attributes written on<Html>reach the page.@trysections render side by side on the server, two sections that read one
streamed value both fill, rows written straight into an@tryfollow it, and
forms inside one callpreventDefaultbefore the browser submits.- A child component reads page props its parent forwarded, and a callback prop
from another file receives its argument. - An inline
attachfunction can read and write state. - An
@ifbranch can show module values in attributes, text and child props. - Markup that calls
String(),Number()and similar on state stays current. - A
<style>written beside or inside a component's root elements ships. - Void elements like
<br>and<img>are written without a closing tag. - Sibling elements with no separator get an error that names the missing
separator. - The first click inside an
@ifor@switchthat is open when a
client-rendered page mounts counts. - A keyed list served empty replaces its
@emptyblock with new rows. - Builds that link packages from outside the project folder name those
modules by package instead of by folder path. - A slow
asyncclick handler no longer holds up every later click on the
page. The next event starts as soon as the handler reaches its first
await. Typing into a field still runs its handlers strictly in order, so
late writes never rewind what you typed. - A callback passed down through two levels of components, and called by the
innermost one, reaches the function it was given instead of throwing
graph.read(...) is not a function. if (event.key !== 'Enter') return; event.preventDefault();cancels only
Enter. Before, the early return was ignored and every key was cancelled,
Tab included.- A click whose target a third-party script removes in the same moment still
runs its handler instead of loggingMARKLESS_EVENT_DISPATCH_UNMATCHED. - A
@forthat renders a component per row, in a component imported from
another file, adds rows in the browser. - A
@forrow variable namederrorbuilds. - A
constdeclared in a component body and passed to a child that reads it
in a handler builds, the same as a module-levelconst. - Text inside
@trythat joins an async value with state, like
{data.title} {count}, now fails the build with
MARKLESS_ASYNC_TEXT_JOIN_UNSUPPORTEDand tells you to put the async value
in its own element. Before, the async part vanished on the first click.
Packing is on by default
Production client builds now group code into a few packs instead of shipping one
file per module. The compiler decides what each page needs right away, what only
matters when navigating to it, and what is not needed yet, using what it knows
about every click. Where it cannot be sure, it keeps the code preloaded, so a
click never waits on a download. Set packing: false to turn it off.
experimentalNativePacking still works but is deprecated, and
experimentalPackPlanner is gone because the planner is now the only way
Markless packs.
File names come from their final contents, and router apps resolve files through
an import map, so a deploy re-downloads only what changed. Shared app code and
the framework ship in separate packs, so editing an app helper no longer
re-downloads the runtime.
Navigation without page reloads
Router links now fetch the next page's server-rendered HTML and resume it in
place. Nothing reloads, persistent layout stays put, and Back and Forward keep
your scroll position. Plain <a> links to your own pages work the same way.
Prefetching is automatic: it starts when the user shows intent, and on pages
with few links it also runs when the browser is idle. It only prefetches real
pages, never server endpoints, and sends a Purpose: prefetch header.
The only setting is prefetch={false}, on the router or on one link. Use it
for links whose request changes something on the server. The linkPreloading
router option is removed.
Faster clicks
Many clicks now run without starting the page runtime or downloading any code.
Handlers apply their changes in the same task as the input, and focus lands only
after the new value is on the page. Clicking a link no longer wakes up the page
you are leaving.
Deploys
If a deploy removes code an open tab still needs, the first click that hits it
reloads the page once instead of doing nothing. Build manifests are no longer
served publicly; they now live in a .markless/ folder next to your build
output, so add .markless/ to your .gitignore.
@markless/ui and icons (held back, see above)
These changes are in the repository and ship when the packages are published.
@markless/ui adds 24 component families, including menu, menubar, toolbar,
tooltip, hovercard, drawer, calendar, datebox, timebox, numberbox, colorpicker,
fileupload, table, gridlist, taglist, tokenbox, rating, editable, resizable,
crop, pad, ink and tour.
Popover placement is now plain CSS: the side prop and the ui-side
attribute are gone from every overlay family, so position overlays in your
stylesheet instead.
The new @markless/icons package lets you use any Iconify icon set as
<pack.icon /> in .tsrx and .mdx. Icons are inlined as SVG at build time.
The ui() Vite plugin from the new @markless/ui-tools package sets this up.
Compiler
storage()works inside a component body, not only at module scope.- A
classprop passed to a child component gets the calling file's scoped
style class, so scoped<style>rules reach it.
Fixes
- Keyed
@forrows update when their data changes under the same key,
including rows built from components, rows reading outer state or props, and
nested lists (inner rows now read the right outer item). - Async
@trycontent keeps working when it streams in, settles in the
browser, or reloads after its data changes. - Text next to other elements updates only itself, so sibling elements (like an
input caret) are no longer wiped. - MDX content passed into a
.tsrxlayout renders as markup and stays
interactive. props.childrenandprops.xcompile like destructured props.- Static text follows JSX whitespace rules.
- Built files no longer contain your machine's folder paths.
- The docs starter template builds again.
- A new app from
create-marklessinstalls with npm and pnpm, passesvp check
before and after a build, and startsnpm run devwithout a server error. @markless/uipages render inpnpm devunder pnpm's strict layout.npm install @markless/uiworks without--legacy-peer-deps.@markless/ui
no longer declaresviteorvitestpeers, which made npm stop with
ERESOLVEonce it pickedvitest5 for Vite's own optional peers.@markless/uiworks invp devwithoutui()invite.config:markless()
now keeps every dependency that ships Markless source out of Vite's
dependency pre-bundling, so the optimizer no longer fails on.tsrxfiles
and blocks the page with an error overlay. Icon tags still needui().- When icon tags like
<lucide.chevronright />reachvp buildwithout
ui(), the build stops with the same message dev shows (addui()from
@markless/ui/vitebeforemarkless()), instead of a CSS parse error
followed by a "checkout path" error. - The starter's
doctorscript builds with the app's ownvp, so
npm run doctorandbun run doctorwork on machines without pnpm. - A
Linkwithparamsno longer writes your machine's folder path into the
build. - Text that joins a
constfrom the component body with state, like
{label} {count}, keeps theconstpart when the state changes. attach={factory(args)}passes its arguments to the factory: props,
literals like['a', 'b'], and module constants, in the page root or a
component used more than once.- An
onVisiblehandler receives an event whosetargetandcurrentTarget
are the element, andonVisibleinside MDX pages no longer throws
MARKLESS_EVENT_DISPATCH_UNMATCHEDon load. onVisibletype-checks on any element; its handler gets the element asevent.target.- Text passed straight in as a component's children, like
<Watch>{count} cups</Watch>, updates when the state changes, the way it
already did when wrapped in an element. - A part that seeds widget state from its own children no longer picks up the
live text passed to a sibling part. - A component that mixes its own state with a destructured prop, like
title={open ? label : ''}, follows the parent's changes to that prop when
both components are in one file, and still renders when the prop is left out. - Parentheses inside a markup expression, like
{(open ? 'Hide' : 'Show') + ' details'}, no longer fail the build with
MARKLESS_TEMPLATE_EXPRESSION_UNSUPPORTED. - A
@forkeyed by position (index i; key i) updates its rows when the list
changes. Before, its rows kept their first text and count. - A keyed row that leaves the list and comes back gets a new element, so it no
longer brings back old state such as a ticked checkbox. - A
@forwritten straight inside an@ifor@else(with no element of its
own around it) keeps the right rows when the branch switches, with no
duplicate rows, and shows its@emptyblock after a switch. - Calling a method on state set from a function call, like
items = items.slice(1)afterstate(makeItems()), no longer throws in a
handler. - A string such as
'" data-size="'in markup no longer raises a false
MARKLESS_TEMPLATE_EXPRESSION_STATICwarning because it contains a state
name. - A click handler on a
@forrow over a component's props, such as
onClick={() => onPick(option)}, receives its own row and keeps working
after the first click. - Parts of a library widget rendered inside a component whose own handlers read
its props, such as@markless/uitab triggers, run their own handlers instead
of the component's. - A component imported through another
.tsrxmodule that re-exports it, like
export { default as Code } from './code.tsrx', renders. - Two components placed on one MDX page, or mounted with two
render()calls
on one document, no longer give theirelement()parts the sameid. A
@markless/uilabel in one example now focuses and ticks its own box, not
the box in the example above it. checkbox.indicatorcan hold an element, like an icon or a<span>, on a box
that starts unticked. The page no longer fails with
MARKLESS_PROJECTION_NOT_RENDERED, and the element shows when the box is
ticked.- Turning
disabledon for onetree.itemafter the page is live no longer
disables the open/close button of the item next to it. - A keyed
@forrow that picks its markup with an@ifor@switchon the
row's own item, like@if (entry.type === 'page'), now builds the rows
added after the page loads. Before, the list kept the rows it first rendered
and ignored new ones, so a@markless/uipagination range never moved. - A component whose whole template is a
@forwith no element around it now
fails the build withMARKLESS_PUBLIC_RENDER_ROOT_UNSUPPORTEDand says to
wrap the loop in an element. Before, it rendered nothing, threw
MARKLESS_MDX_RENDER_DATA_ROOT_MISSINGon an MDX page, or stopped a page
that imported it from loading. @markless/uiotpboxes show what you type on router pages, both MDX pages
and plain routes, in dev and in production builds. Before, the code reached
the field but every box went blank. The same applies to any part that works
out its position from its widget's list ofelement()parts, when the page
places that widget through another component.- An element bound inside an
@ifblock to anelement()handle declared in
the same component now gets theidthataria-controls,foror another
id reference outside the block points at, instead ofid=""when the block
appears. A reference inside the block to such an element outside it gets the
right id too. checklistitems follow the group after the page is live: the select-all
ticks and unticks them, anddisabledset onchecklist.rootby the page
locks them. Items built in a@fortick on click instead of throwing
MARKLESS_CALLBACK_SLOT_UNRESOLVED.- A prop passed a value from a
@forrow, like
<carousel.item value={row.value}>, builds instead of failing with
MARKLESS_CAPTURE_OPAQUE_PROPwhen the component imported from another file
only shows that value. A click handler that reads it is still refused. - Router links land a new page at the top, or Back and Forward at the saved
position, in one step. Before, a page withscroll-behavior: smoothshowed
the new page at the old offset and then animated the scroll. A scrolled box
such as a sidebar also keeps its offset when the next page wraps it in a
different element, if the box has anid; before, it jumped back to the top.