Skip to content

v0.5.0

Choose a tag to compare

@thejackshelton thejackshelton released this 17 Oct 23:31

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. A storage() value is now also saved
    in a cookie named markless-<key>, so the server renders the saved value in
    the first response. A saved tab or theme no longer flashes its default.
    Pages that read storage() are sent Cache-Control: private and
    Vary: Cookie.
  • Pages can read the request and set the response. Every page gets an
    http prop with request, url, locals and response. Set
    http.response.status or headers before the page starts streaming, for
    example a 404 page. http is server-only: reading it from browser code fails
    the build with MARKLESS_SERVER_ONLY_PROP_BROWSER_READ.
  • Streamed content is adopted, not re-rendered. When an @try section
    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>.js with 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-markless asks which editor you use (VS Code, Cursor, Windsurf,
    Zed, or other) and writes only that editor's settings. Pass --editor to
    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 Link without href builds, so links whose children come from data work.
  • MDX pages render lowercase JSX tags and {/* comments */}.
  • document.tsrx renders child components and @for, @if and @switch,
    and the attributes written on <Html> reach the page.
  • @try sections render side by side on the server, two sections that read one
    streamed value both fill, rows written straight into an @try follow it, and
    forms inside one call preventDefault before the browser submits.
  • A child component reads page props its parent forwarded, and a callback prop
    from another file receives its argument.
  • An inline attach function can read and write state.
  • An @if branch 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 @if or @switch that is open when a
    client-rendered page mounts counts.
  • A keyed list served empty replaces its @empty block with new rows.
  • Builds that link packages from outside the project folder name those
    modules by package instead of by folder path.
  • A slow async click 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 logging MARKLESS_EVENT_DISPATCH_UNMATCHED.
  • A @for that renders a component per row, in a component imported from
    another file, adds rows in the browser.
  • A @for row variable named error builds.
  • A const declared in a component body and passed to a child that reads it
    in a handler builds, the same as a module-level const.
  • Text inside @try that joins an async value with state, like
    {data.title} {count}, now fails the build with
    MARKLESS_ASYNC_TEXT_JOIN_UNSUPPORTED and 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 class prop passed to a child component gets the calling file's scoped
    style class, so scoped <style> rules reach it.

Fixes

  • Keyed @for rows 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 @try content 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 .tsrx layout renders as markup and stays
    interactive.
  • props.children and props.x compile 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-markless installs with npm and pnpm, passes vp check
    before and after a build, and starts npm run dev without a server error.
  • @markless/ui pages render in pnpm dev under pnpm's strict layout.
  • npm install @markless/ui works without --legacy-peer-deps. @markless/ui
    no longer declares vite or vitest peers, which made npm stop with
    ERESOLVE once it picked vitest 5 for Vite's own optional peers.
  • @markless/ui works in vp dev without ui() in vite.config: markless()
    now keeps every dependency that ships Markless source out of Vite's
    dependency pre-bundling, so the optimizer no longer fails on .tsrx files
    and blocks the page with an error overlay. Icon tags still need ui().
  • When icon tags like <lucide.chevronright /> reach vp build without
    ui(), the build stops with the same message dev shows (add ui() from
    @markless/ui/vite before markless()), instead of a CSS parse error
    followed by a "checkout path" error.
  • The starter's doctor script builds with the app's own vp, so
    npm run doctor and bun run doctor work on machines without pnpm.
  • A Link with params no longer writes your machine's folder path into the
    build.
  • Text that joins a const from the component body with state, like
    {label} {count}, keeps the const part 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 onVisible handler receives an event whose target and currentTarget
    are the element, and onVisible inside MDX pages no longer throws
    MARKLESS_EVENT_DISPATCH_UNMATCHED on load.
  • onVisible type-checks on any element; its handler gets the element as event.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 @for keyed 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 @for written straight inside an @if or @else (with no element of its
    own around it) keeps the right rows when the branch switches, with no
    duplicate rows, and shows its @empty block after a switch.
  • Calling a method on state set from a function call, like
    items = items.slice(1) after state(makeItems()), no longer throws in a
    handler.
  • A string such as '" data-size="' in markup no longer raises a false
    MARKLESS_TEMPLATE_EXPRESSION_STATIC warning because it contains a state
    name.
  • A click handler on a @for row 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/ui tab triggers, run their own handlers instead
    of the component's.
  • A component imported through another .tsrx module 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 their element() parts the same id. A
    @markless/ui label in one example now focuses and ticks its own box, not
    the box in the example above it.
  • checkbox.indicator can 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 disabled on for one tree.item after the page is live no longer
    disables the open/close button of the item next to it.
  • A keyed @for row that picks its markup with an @if or @switch on 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/ui pagination range never moved.
  • A component whose whole template is a @for with no element around it now
    fails the build with MARKLESS_PUBLIC_RENDER_ROOT_UNSUPPORTED and says to
    wrap the loop in an element. Before, it rendered nothing, threw
    MARKLESS_MDX_RENDER_DATA_ROOT_MISSING on an MDX page, or stopped a page
    that imported it from loading.
  • @markless/ui otp boxes 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 of element() parts, when the page
    places that widget through another component.
  • An element bound inside an @if block to an element() handle declared in
    the same component now gets the id that aria-controls, for or another
    id reference outside the block points at, instead of id="" when the block
    appears. A reference inside the block to such an element outside it gets the
    right id too.
  • checklist items follow the group after the page is live: the select-all
    ticks and unticks them, and disabled set on checklist.root by the page
    locks them. Items built in a @for tick on click instead of throwing
    MARKLESS_CALLBACK_SLOT_UNRESOLVED.
  • A prop passed a value from a @for row, like
    <carousel.item value={row.value}>, builds instead of failing with
    MARKLESS_CAPTURE_OPAQUE_PROP when 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 with scroll-behavior: smooth showed
    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 an id; before, it jumped back to the top.