Skip to content

Neo.mjs v13.3.0 Release Notes

Choose a tag to compare

@neo-opus-grace neo-opus-grace released this 21 Oct 10:40
0af772e

Release Type: Grid Fits Its Content, a focused features release: the first on the one-to-two-week cadence, with the package's entry points unchanged
Stability: Stable
Upgrade Path: A drop-in replacement for 13.2. All three new grid features are opt-in and the package's entry points are unchanged; see Upgrading from 13.2 below.

TL;DR: A grid with a fixed row height has to choose: clip long text, or make every row as tall as the longest one. In Neo.mjs 13.3, each row can fit its wrapped text instead, measured by the browser's own layout, and the row you are reading holds still while the heights arrive. Header menus filter the same grid by checked values, with counts that respect every other filter, and a search toolbar finds text across its records. All three are opt-in; for everything else, 13.3 is a drop-in update.

The issues showcase in the dark theme: neomjs/neo's own issues in a grid whose rows are as tall as their wrapped title or summary, with the issue number locked at the start, type, area and author in the center, and the state locked at the end


📋 13.3 in 2 Minutes

New to Neo.mjs? It is an agent institution, persistent AI maintainers with identities, memory and peer review, and the multi-threaded application engine they build, in sibling open-source repositories (README). This note covers the engine. In the engine, your app's components, state and data stores run in a worker, the App Worker; the main thread applies DOM changes and answers layout questions, so your application logic stays off it.

Who builds it: AI maintainers from the Claude, GPT and Gemini model families, and one human maintainer, Tobi (@tobiu); see the team. This release's pull requests came from Ada (@neo-opus-ada), Grace (@neo-opus-grace), Vega (@neo-opus-vega), Mnemosyne (@neo-fable-mnemosyne) and Clio (@neo-fable-clio) on Claude, Euclid (@neo-gpt-euclid), Sophie (@neo-gpt-sophie) and Emmy (@neo-gpt-emmy) on GPT, Julia (@neo-gemini-julia) and Maryam (@neo-gemini-maryam) on Gemini, and eight outside contributors. The maintainers review each other across model families, every Engine pull request runs the tests its change can reach, and Tobi merged every pull request in this release except fifteen automatic Dependabot bumps.

The one line: the grid fits its content.

Three opt-in settings:

  1. Content-sized rows (#19658). contentSizedRows: true sizes each row to its tallest wrapped cell, measured by the browser, and keeps locked columns aligned. The row you are reading holds still while measured heights replace estimates.
  2. Header menus (#19643). columnMenu: true gives each column a value picker with search and live counts, opened by right-click, a hover arrow, the keyboard or touch. The counts respect the grid's other filters.
  3. A search toolbar (#19794). searchToolbar: {} adds a search field above the headers. It searches this grid's columns, locked ones included, or all record fields, or one column, with a case toggle. A summary lists the active header filters, and Clear filters removes only the grid's own filters.

Try it: the issues showcase shows all three on Neo's own 497 most recent issues. The Workstation, the multi-window flagship app from 13.2, carries this release's docking fixes: open it in desktop Chrome and pull a tab past the window's edge. What you can try below walks through both.

The limits: content-sized rows are proven at 10k rows, and they size cells by their text, not by images or components. Header menus and the search work on local data only. Each chapter's Honest bounds says more.

Under the hood: every Engine pull request now runs the end-to-end tier that hosted CI used to skip.

The scale: 149 pull requests merged in neomjs/neo and 148 issues closed since 13.2.0 shipped on 2026-10-11. The Full Changelog lists them.

Read for your work: the grid chapters for text, filtering and search, Tests that drive the real app and One day of release testing for how the engine is verified and who fixed what testing found, Upgrading from 13.2 if you ship the engine, and For maintainers if you cut releases.


📏 Content-sized rows: the grid fits its text

A buffered grid keeps a small pool of row elements and moves them over a large store as you scroll. To place row 5,000 and size the scrollbar, it needs every row's height, so until 13.3 it assumed one height for all of them: wrapped text was cut off, or every row was tall enough for the longest cell. Variable row heights had been on the grid's backlog since March (#9495). 13.3 lets a grid drop the assumption:

{
    module          : GridContainer,
    contentSizedRows: true,
    store           : MainStore,
    columns         : [
        {dataField: 'title',   locked: 'start', text: 'Title',   width: 160, wrapText: true},
        {dataField: 'summary',                  text: 'Summary', width: 280, wrapText: true},
        {dataField: 'owner',                    text: 'Owner',   width: 110}
    ]
}

Each row takes the height of its tallest wrapText cell. A grid can lock columns at its start and end around a scrolling center, and all three regions share each row's height, so the locked columns stay aligned. In a tree grid, a wrapText tree column wraps its labels, and the helper lines keep spanning each row (#19829). Fixed height stays the default, and a fixed-height grid is unchanged.

The issues showcase in the light theme: every row as tall as its longest wrapped text, the title in the locked start column or the summary in the center, from two lines to four

Measured by the browser, not predicted

In Neo.mjs, measuring the text is the hard part. The grid's logic runs in the App Worker, which has no DOM, and a height predicted from font metrics is only an estimate: fonts, scripts, zoom and CSS each move it. So the App Worker does not predict; it asks the browser. A hidden pre-render lays out a batch of cells with the grid's own cell CSS, and one layout pass yields every height, sent back in a single round trip. The engine already had both parts, rendering text into a hidden measuring element and reading many element sizes in one call, so the feature needed no text-layout library. Neo builds on its own primitives before it reaches for one (the design discussion).

A probe priced the pass at 300 cells: 3.6 ms in Chromium 153, and 4 ms in Firefox 155 and WebKit 26.6 (medians of 7 runs). The worker-to-main round trip stayed under 0.1 ms.

Keeping your place while heights arrive

Rows not yet measured start from an estimated height. When a measured height replaces an estimate above the row you are reading, everything below it would shift. 13.3 corrects the scroll position in the same update, so the visible row stays put (#19674), and a row reached by keyboard keeps its edge while the rows around it are measured (#19699). Switching a grid between content-sized and fixed rows keeps the same rows in view: the switch used to keep the scroll's pixel offset, which in shorter fixed rows belongs to a row much further down (#19909, #19911). One row-geometry model per view, the height and offset of every row, drives scrolling, the scrollbar and keyboard navigation (#19668). The AnimateRows plugin's row animations do not run in this mode (#19677).

Case study: one pixel between macOS and Linux

Every local run passed on macOS. In CI, on Linux, a row of the 10k example shrank from 62px to 43px the moment its column mounted. The hidden measuring cells kept a 1px right border that the displayed cells drop on a region's last column. Under CI's fallback font, which shares Arial's metrics, that one pixel wrapped the row's text to three lines in the hidden cell and two in the displayed one. The fix builds the hidden cells with the same last-column flag as the displayed ones, and a unit test compares a hidden cell's classes with its displayed cell's, so the two cannot drift apart again. Swept over five fonts, two runs each, no row changed (#19704).

That is the condition the design rests on: the measuring cells must match the displayed cells exactly. With that held, the heights are the browser's own layout, every font, script, zoom level and CSS rule included.

Case study: a recycled row kept its previous record's height

On an early 100k-row draft of the content-sized example at 1400×900, jumping to row 99,996 and pressing ArrowDown four times left the last row 100px tall, while its text needed 81px. 100px was the height of the record that had last used that pooled row element. The engine's virtual DOM had moved on to the new record, the page's DOM had not yet, and nothing flagged the gap, so the measurement read the old text. Before the fix, 18 of 20 headless runs ended with the stale height; after it, none of 20. A cell is now read only once the DOM shows it (#19692, #19695).

The proof

The design discussion listed ten examples the feature had to handle. Each is now a named end-to-end test, checked against the browser's layout: a row must fit its tallest text plus the cell's padding and borders (#19704).

Honest bounds

  • Proven at 10k rows, not 100k. Past 2^23 px of scroll height, Chrome rounds scroll offsets to 2 px, and browsers cap element heights at different limits (#19658). Scaled scrolling beyond that is deferred.
  • Cells size by their text, and tree cells by their label. A tree label is measured once its row renders, not ahead of the scroll. Images and other components in cells, remote data, and uniform or capped N-line modes are deferred.

🔽 Grid header menus: filter a column by its values

An engine user asked for a header menu whose first tool is a value picker with counts. 13.3 ships it, opt-in:

{
    module    : GridContainer,
    columnMenu: true,
    columns   : [
        {dataField: 'firstname', text: 'Firstname'},
        {dataField: 'id',        text: 'Id', columnMenu: false}
    ]
}

Right-click a header, or use the arrow button that appears on hover or focus, Shift+F10, the ContextMenu key, or touch. One floating picker opens, with search, a count beside every value, and checkboxes that filter at once. The selection belongs to the store, so two grids on one store, or a header rebuilt later, show the same filter. A filter stays visible after its picker closes: a filtered column keeps its arrow shown and filled, labelled for example "Filter Status, active", and a header without a menu marks itself with a line along its bottom edge (#19805). Sorting, resizing and reordering keep their own paths (#19711).

In the showcase: the Author menu with each author's issue count, then the Area menu searched for "Gri", with Grid checked and the grid down to its 71 Grid issues.

The showcase's Author menu, opened from the header's arrow: a search field above the authors' logins, each with its issue count, such as neo-opus-ada 159 and neo-opus-grace 130

The Area menu searched for Gri, with Grid checked and a Clear button; the grid behind it shows only Grid issues, and the subtitle reads 71 of 497 issues

Counts that respect the other filters

A value's count is how many rows hold it under every other active filter: check Bug in the Type menu, and the Area menu counts bugs only. The store counts its raw data in the App Worker, off the main thread. It writes nothing, builds no record instances, and neither removes nor reapplies a filter. On the 100k-record bigData store, sampled raw records stay unchanged through open, search and recount (#19673, #19711). One Firstname count there takes 88–165 ms of worker CPU, and a recount after checking a value settles in 313 ms (#19711).

Whose filter is it?

The store used to find a filter by its property, and the first match won. A test run during the design showed what that would do to a menu: in two creation orders, the picker's filter and an application's own filter on the same field overwrote each other (the test). Now Collection and Filter identify a filter by its owner's role and its field, so a menu's filter and your code's filter on the same field coexist. Looking a filter up by property alone still works for existing code (#19673).

Typed values

Options sort by their typed values: numbers by value (1, 2, 10, 20, 100), dates by time, each type in its own group, and text in natural order. Dates get locale-aware labels, and empty strings, null and undefined show as (blank), (null) and (undefined) (#19724). Checking a value refreshes the open menu once.

A menu lists at most 1,000 unchecked values that match its search. On the id column of 100k records, a footer asks for a narrower search:

The id column's menu at 100,000 rows, scrolled to its end: values up to 1000, each with a count of 1, then the footer 100,000 values match, narrow the search

The proof: 23 end-to-end cases in five spec files drive the menus in a real browser.

Honest bounds

  • Opt-in by design. A grid without columnMenu renders its headers as before, and that is tested.
  • Local data only: the counts read the store's local records. Remote, partial and tree stores are deferred.
  • When a column's values cannot be counted, as with remote or tree data, data still loading, or values the picker cannot compare, the menu says so instead of guessing.
  • The 100k timings come from one measurement, not a performance promise.
  • Deferred: exclusion mode, hide, lock and width commands, and a column chooser. The grid-level search shipped in 13.3 as its own toolbar, described next.

🔎 Search the grid: one field across its records

A header menu filters one column by its values. A record search looks for text across many columns at once, and 13.3 ships one as an optional toolbar above the headers (#19794, epic #19791, PR #19804). Set searchToolbar: {} on a grid. Without it, the grid renders as before.

Each term you type, split at whitespace, must match at least one searched field. Matching ignores case unless the Aa toggle is on. A new search covers this grid's columns, locked ones included. All record fields also searches values that no column shows, and the scope menu can narrow the search to one column. A column whose values cannot be searched as text is listed as not searchable.

Below the field, a summary lists each active header filter as a removable entry. It also notes what the grid does not own, such as a filter your application set, and says when a search is disabled, has no searchable field, or was refused. Clear filters removes exactly the grid's own filters: header filters, value pickers and the search. Your application's filter stays, and the summary keeps naming it.

The Store owns the search

The search is a filter on the Store, not state inside the toolbar (#19792). Two grids on one Store therefore share one search, and destroying or recreating a grid or its toolbar leaves the search intact. A remote or tree store refuses the search and says why, instead of searching only part of its data.

Honest bounds

  • Literal terms only. Regular expressions are deferred: a sampled pre-check cannot bound a pattern's run time in the App Worker (#19786).
  • Local data only, like the header menus.
  • On the 100k bigData grid, typing Amanda one character at a time measured 7.3–12.1 ms of input dispatch and 443.9–638.0 ms until the rows painted, per keystroke, on one M5 Max machine. These are end-to-end browser observations, transport and polling included, not matcher timings and not a promise (#19804). Every keystroke filters again; no write coalescing exists yet.

🧪 Tests that drive the real app

A green test suite proves only what it runs, and only against the app it actually drives. 13.3 closed a gap of each kind.

The skipped tier. Until 13.3, hosted CI skipped most of the engine's end-to-end tests: the ones that drive a running app inside its worker through the Neural Link, Neo's bridge into a live App Worker. 96 of 145 spec files never ran there (the discussion), and a dock regression from #19559 sat unseen on dev for about 14 hours, until a bisect found it.

Now every Engine pull request runs that tier, and a red blocks the merge (#19705). A change that cannot reach a spec's area runs a smaller smoke set. At the promotion, the tier was 266 tests on ten shards, all passing, one of them after a retry; its slowest step stayed under four minutes against a five-minute ceiling.

It found engine bugs before it blocked anything. First the tier ran only on dev, where a red stopped nothing, and measured its hosted time (#19667). It found three engine bugs, and each was fixed for its class:

  • A component moved into another browser window could loop on a "mounted" promise left over from its earlier mount; this was the burn-in's most frequent failure. An unmounted component now hands out a pending promise (#19689).
  • A tab strip collapses tabs that do not fit behind an overflow menu. It read a tab header destroyed mid-update as live, and logged its own teardown as a defect (#19701).
  • Undoing the close of a popup window could leave its pane in neither window: a layout update from the main window ran about a second late and took the pane back. A layout update now stands down once a newer one has placed the pane (#19703).

🕵️ War Story: an async predicate that never waited

Symptom. While the header menu was being wired into the grid, a test that opened a fresh page of the bigData example sent its writes into an older session of the same example, one with header menus already on (#19713).

Investigation. The test fixture's connectToApp attaches a test to the page's App Worker, and it asked the page for that worker's id through page.waitForFunction(async () => …). A probe on the pinned Playwright 1.63.0, against a page that sets a value after 1,500 ms:

Predicate Result
async () => window.id ?? null null after 9 ms
() => window.id ?? null "ready" after 1,492 ms

Culprit. Playwright treats the async predicate's Promise as truthy, so the wait resolved on its first read and never polled. During bootstrap that read is null, and the fixture fell back to binding by app name, which resolves to the oldest session with that name. All 139 connectToApp calls pass a name, and the async predicate had been in the fixture since 2026-06-11 (#12904).

Fix the class. connectToApp no longer binds by name (#19715). Given a name, it polls for the page's own App Worker id for up to 30 seconds and fails if none arrives; given an App Worker id, it binds that id. With the old read restored, a new test that first connects an older same-name session goes red.

The portable lesson: a waitForFunction predicate must return its value synchronously. An async predicate returns a Promise, which is truthy, so the wait ends at once.


🗓️ One day of release testing

Some defects show only when someone uses a release the way a newcomer would. On 2026-10-20, Tobi tested 13.3 by hand, and a headless sweep loaded all 158 example and app pages on the development server, recording page errors, console errors, failed requests and duplicate element ids. Findings small enough for a first contribution were filed for outside contributors, with the reproduction and the files to read. The day's findings and their fixes, in UTC:

Filed Finding Fixed by Merged
08:43 A header-menu option with one match read "1 matches" to screen readers (#19878) @tnalbant (#19884) 10:32
10:03 Sorted grid and table headers did not announce their direction (#19886) @sunred-liu (#19908) 12:53
11:13 Some relative links in blog posts left the Portal for a 404 page (#19901) Maryam (#19937) 10-21 09:06
11:13 The Portal's theme switch did nothing on its first click (#19902) @abhinav-rb (#19940) 10-21 10:00
11:13 Nine links in blog posts and a guide pointed at files that had moved (#19903) @bercedev (#19925) 18:38
11:34 The header-menu arrow could not be seen in either of the showcase's themes (#19910) @MFA-G (#19915) 16:09
13:30 A mount hook that throws made the next update insert its siblings' elements a second time (#19916) Grace (#19921) 17:57
13:39 RealWorld v1 rendered without its theme and showed its Popular Tags sidebar twice (#19917) Grace (#19935) 10-21 09:05
15:54 Seven examples throw on load and render nothing (#19920) @bercedev, four of the seven (#19923, #19924, #19938, #19939), and @Uddoo, one (#19945) 19:20 to 10-21 09:12
16:23 A child's update merged into its parent's pending update was dropped when the two belonged to different windows (#19922) Maryam (#19936) 22:33
18:23 An update that changes an element's tag wrote the rest of its changes to the element it had just replaced (#19926) Julia (#19927) 20:05
19:42 The header menus' value list clipped its focus ring into a double border, and showed olive rows in the dark theme (#19928) Ada (#19929) 20:28

Tobi called the last finding a release blocker. Its fix merged 46 minutes after the ticket, with a browser test on the showcase. Three of the day's findings were engine defects behind an app's symptom:

  • The mount hook. RealWorld2's home view rendered twice. One list's afterSetMounted threw, calling an addon the app had dropped, and that stopped the loop that marks each child component mounted, so the parent's next update inserted the remaining children's elements again. Each child's mount now runs on its own, and a hook that throws is logged with its component's id.
  • The tag change. CI on @bercedev's form-layout fix went red in a test the fix did not touch, one that boots a workspace from source: its greeting rendered empty. A component that changes its element's tag and its text in one update gets a new element, but the update kept writing to the replaced one. Julia traced it to DeltaUpdates, the main thread's DOM writer, which now continues on the new element.
  • The window. RealWorld v1's Popular Tags sidebar sometimes stayed empty, although the tags had arrived. The tag list was created before its view knew its window, and its update, merged into the home view's pending update, was dropped because their windows differed. Maryam's fix merges a child only into a parent of its own window, and the app now gives its views their window when it creates them.

Honest bounds: not every finding is fixed. Two of the seven examples still fail on load, as they did in 13.2.0, each open as a good first issue: the calendar week view and the popover (#19930, #19933).

🤝 From the community

Eight people from outside the team merged pull requests in this window, seven of them for the first time. Four did it on that one day: @tnalbant, @sunred-liu, @MFA-G and @bercedev, whose five pull requests are in the table above. The next morning, @Uddoo's first pull request fixed one more of the day's examples, and @abhinav-rb's made the Portal's theme switch work on its first click.

Earlier in the window, @sloemo01, whose component spec for the Portal's CSS 3D cube appeared in 13.2, closed a ticket filed on 2023-02-17 (#4069): reordering the items of a header toolbar should keep the gaps between them. The base sort zone's switchItems already did. #19710 pins its gap math for the tab, grid and table headers, including the unequal-size case behind the ticket's 1.5px drift note. And @taha-yasin-saad pinned how Neo.core.IdGenerator names every instance, one test per row of the ticket's table (#19176, #19874).

Thank you, all eight. More good first issues are open: comment on one to claim it.


🧰 Also in 13.3

Grid and lists

  • Animated sorting moves each card once. An animated component list moved its retained cards twice before settling on a new sort order; now they move straight to the latest order. The store marks the load a sort emits with isSort: true, so adding, removing or clearing cards still rebuilds the list at once (#19717, #19738, #19752).
  • Animated row sorting works again. The grid's AnimateRows plugin predated the grid's pool of reusable rows, and it threw on every header sort and filter in its own example. It now moves each record from its old row position to its new one and fades in the records that enter. Rows transition only while they move, so scrolling never animates (#6858, #19774). The example's Transition Duration field threw on every change and now sets the duration of both bodies (#19889, #19890).
  • Locked columns follow the grid's settings. Since locked columns got their own bodies and headers (#9487), those regions ignored the grid's body and headerToolbar configs: a locked column had no header filters, sorting options, row animation or modified-cell highlighting. They now carry them, and the animated sorting example locks its Id column to show its rows moving with the rest (#19772, #19777). A sort also clears the arrows in every region, and a runtime change to sortable, showHeaderFilters or useTriStateSorting reaches the locked headers (#19798, #19799).
  • Three sorting fixes. A sort by a field named index emptied the collection, as one click on the covid grid's # header showed: the sort's internal map kept each item's position under that same key, and the field's values overwrote it (#19800, #19802). A header set to sortable: false also still sorted on click; it no longer does (#19801, #19803). And a click into a header's filter field, in the grid or the table, sorted its column. The header now skips clicks inside the field, and a caption click still sorts (#19787, #19788, #19790, #19795).
  • A sorted grid stays sorted while you filter it. Once a collection had been filtered before it was sorted, every later refinement came back in creation order under a sort arrow that still said ascending. A filter change now re-applies the sorters. 13.2.0 had the same defect, but 13.3's header menus and search make sorting and then narrowing the routine path (#19904, #19907).
  • Screen readers hear the sort, and the count in the right number. Sorted grid and table headers announce their direction through aria-sort (#19886, #19908), and a header-menu option with one match reads "1 match" instead of "1 matches" (#19878, #19884). Outside contributors fixed both on the day they were filed; see One day of release testing. A browser test now reads aria-sort off the rendered headers, and a header that is not sorted no longer updates on every sort (#19913, #19914).
  • Row hover crosses the locked seams. In a grid built with locked columns, hovering a row highlighted its center cells but not its locked ones: the sync between the regions registered only after a later column change. It now registers when the grid mounts (#19822, #19824).
  • A destroyed grid releases its main-thread addons. A grid destroyed in the tick it mounted threw a TypeError, and a destroyed grid left its drag-scroll, row hover and scroll-sync registrations on the main thread. A registration that resumes after destroy now stops, and destroy retires every one the grid holds (#19820, #19821).
  • A buffered list's rows show their new records after a scroll. Under heavy CPU load, a scroll could re-bind the pooled rows of a Neo.list.Buffered in an update that shipped none of them, so they kept showing their previous records until a later update. The re-bind now ships every row, as grid.View already does (#19762, #19763).
  • Column renderers named by a string work again. A column's renderer: 'methodName' resolves to its view controller's method. Before, every row threw, and examples/grid/nestedRecordFields showed its headers and no rows; the defect predates 13.3 (#19839, #19840).
  • Lists in the neo themes keep their keyboard focus ring inside the row. The ring had grown to 3px but kept a 1px inset, so a list that scrolls clipped it at its sides (#19929).
  • One grid keyboard-navigation failure stays open. It is intermittent, and a failing run now captures the grid's state for diagnosis (#19654); the failure itself is #19515.

Docking, windows and the Workstation

  • Dock animations finish before a resize. Dock layouts land their animated geometry with CSS transitions suppressed, so a native window resize reads the landed geometry (#19708).
  • The Workstation's film tour restores its saved two-window arrangement. The Workstation is the multi-window Dock Layouts app from 13.2, and its film tour plays a scripted ten-scene walkthrough (#19569, #19731). It was one of the two restore defects the roadmap carried; the other, #16498, stays open.
  • A pane dragged home by its window's titlebar closes that window when main adopts it. The emptied Workstation window stayed on screen, showing only its departing background, until main's panes finished moving, about 600 ms longer. It now waits only for the adoption and its own departing fade (#19778, #19797).
  • A popped-out Workstation pane stays out. Moving the pointer into a popped-out window, or resizing it in place, could dock its pane back into main. A native title-bar drop now starts only when the window's origin moves (#19841, #19842).
  • Panes carried back to main land where main previewed them. A pane torn out by its tab and carried back over the main window landed in its popup although main previewed the drop, and a second popup returned by its OS title bar never docked; both shipped with 13.2.0 (#19866, #19867). A pane returned by its title bar right after another one now docks too (#19868, #19870). And a drag that does not land where its window previewed it now reports itself, with one logged error per gesture and the last 20 kept for inspection, so every Workstation drag test also checks its landing (#19869, #19871).
  • Two more dock fixes. A dock tab group whose tabs had been reordered showed no More tabs control once its tabs overflowed (#19885, #19895). And a Workstation tour started right after a reload could leave main's dock host with an orphaned second shell, because the reload's dock projection and the tour's ran side by side; tours now queue behind a projection in flight (#19879, #19880).
  • A window closed while it boots releases what it held. A torn-out window closed before it finished booting kept its tear-out slot, so the next tear-out of the same pane opened no window (#19898), and a theme file it was still loading could park every later update of the app (#19899). Both are released now (#19918).

Engine core

  • A beforeSet that throws keeps the previous value. A config hook that rejected a value by throwing still left that value installed; the previous value now stays (#19851, #19852).
  • A functional component destroyed mid-update no longer freezes its ancestors' updates (#19830, #19831).
  • A child in another window keeps its update. A child's update merged into a pending update of its parent was dropped when the two belonged to different windows. A child now merges only into a parent of its own window, and one whose window changed before the parent's update runs gets an update of its own (Maryam, #19922, #19936).
  • Two examples' engine causes, fixed from outside the team. A container that inserts an item while it is still being configured, before its layout exists, no longer throws; createItems() applies the item's layout attributes once the layout is created (@bercedev, #19932, #19939). The date selector's day view waits for its date and its bound weekday format before it renders, and follows later format changes (@Uddoo, #19931, #19945).

Apps and the Portal

  • The Portal features the Workstation first in every example mode (#19641). The Portal is the neomjs.com site. The npm 13.2.0 package still carries the old catalogs, so the site serves the correction from an overlay until 13.3.0 publishes it.
  • Portal links that name a number stay whole. The ticket autolink split a Markdown link whose text named a number, such as a blog post's link to a Discussion, and the second half opened the tickets view for a number that is no ticket, which then loaded every chunk folder. Both are fixed (#19900, #19912).
  • The Portal's theme switch works on its first click after a fresh load (@abhinav-rb, #19902, #19940).
  • The Portal's example galleries list the issues showcase, next to the other grid demos in every mode (#19947, #19948).
  • The RealWorld apps load their data again. Their API singletons waited on a static Base.on('ready') that no class provides, so every singleton after the first threw; they now share one stored-token promise (#19700). Their backend had gone offline as well, and both apps now call the RealWorld project's hosted API at api.realworld.show (#19882, #19883). RealWorld2 also mounts its views once again: its addon list had dropped Navigator, so its home view rendered twice (#19891, #19892). RealWorld v1 renders in its Conduit theme again, with one Popular Tags sidebar, and its article page renders: since February, a hole in the article body's children made its update throw, so the page showed nothing (#19917, #19935).

▶️ What you can try

Open the issues showcase, a grid of Neo's own 497 most recent issues:

  1. Scroll. Each row is as tall as its title or summary needs, and the issue number and state stay aligned in their locked columns.
  2. Untick Content-sized rows to see the same issues at a fixed height, each title and summary cut to one line, then tick it again.
  3. Open the Type menu from its header's arrow and check Bug: 270 of 497 issues remain. Then open the Area menu: its counts now count bugs only.
  4. With Bug still checked, type header menus into the search field above the headers: 2 of 497 issues remain, and the toolbar's summary reads Type: Bug, since search and header filters apply together. The scope button reads Grid columns; open it to search all record fields or one column instead. Clear filters then removes both, and all 497 issues return.
  5. Switch to the dark theme with the button in the header.

On your own grid, set contentSizedRows: true on the grid and wrapText: true on its text columns, columnMenu: true for the menus, or searchToolbar: {} for the search. For scale, open the 10k content-sized example, or the 100k bigData example and tick Column menus on the Settings tab behind its ☰ button (#19750). From a checkout, after dependencies and fresh theme assets are in place, npm run server-start -- --no-open serves the same examples on the printed local port (source).

Then open the Workstation in desktop Chrome:

  1. Pull a pane's tab past the browser window's edge: the pane becomes a window of its own mid-gesture.
  2. Carry it back over the main window. Main previews where it will dock, and it lands there.
  3. Press Start film tour to play its ten scenes across real windows. The tour stops at Continue before each beat that opens a window, because a browser opens one only inside a click. Allow pop-ups for the site once, and the windows the tour opens mid-drag are real ones too.

🧭 What comes next

Releases now come every one to two weeks. The cadence is fixed and the scope flexes: work that misses a window moves to the next release instead of holding the cut. 13.4's theme is not set yet; the roadmap will name it. What 13.3 deferred is on record:

  • Content-sized rows: more than 10k rows, images and components in cells, measuring tree labels ahead of the scroll, and remote data.
  • Header menus: remote, partial and tree data, exclusion mode, column commands and a column chooser.
  • Search: regular expressions, and remote and tree data.
  • Grids across windows: a grid's locked or scrolling region moved into another browser window, with scrolling kept in sync on the main thread and keyboard navigation across the regions (#9486).
  • The engine: a rebuilt middleware for server-side rendering (#12964), and Pointer Events for drag and drop (#17889).

Found a gap, or built something with 13.3? Open an issue, start a Discussion, or find us on Discord and Slack.


⬆️ Upgrading from 13.2

Nothing to change. package.json's main, exports, bin, engines, type and dependencies are unchanged since 13.2.0; besides the version, it changes only metadata: the devDependency neo-agent-skills 0.1.39 → 0.1.55, and three keywords dropped. All three grid features default off: contentSizedRows: false on the grid, columnMenu: false on the grid with a per-column override, and searchToolbar: null.

A reason not to wait: Chromium 156. From Chromium 156, the version Playwright 1.64 ships, a grid's computed overflow changes from hidden/hidden to hidden/clip, and its automatic minimum height lets a flex-sized grid grow past the space its layout gives it. 13.3 sets min-height: 0 on .neo-grid-container, so the grid's view does the scrolling again (#19755, #19690). If you stay on 13.2 for now, add that rule to your own CSS.

Public behaviour to check:

If your code… In 13.3 Do this
awaits mountedPromise on a component that is not mounted the promise stays pending until the next mount; before, a promise settled by an earlier mount could be returned at once (#19689) nothing, unless you relied on the early resolve
catches the rejection of a remote call whose target window is closing the error keeps its PortDisconnectedError name and message, and adds the code NEO_DEAD_PORT and the target window id (#19728) branch on the code if you need to
loads a Canvas renderer through SharedCanvas whose import can fail the host no longer reports ready; it emits canvasLoadFailed (#19734) listen for the event if you need a fallback
has a mount hook (afterSetMounted, afterVnodeSync) that throws the error is logged with the component's id, and its siblings still mount; before, the update was rejected, and the next one inserted the siblings' elements again (#19921) fix the hook: the update's promise no longer reports it
looks a store's filter up by property while a header menu filters the same field each writer owns its filter by (role, dataField); the property-only lookup still returns the first match (#19673) nothing

🛠️ For maintainers: a release is a pull request

For the people who cut releases or contribute to Neo; skip it if you only use the engine.

13.2.0's release script predated dev's ruleset, which requires CodeQL and 18 checks on every pushed commit, so 13.2.0 shipped through a temporary bypass and by hand (#19637). Tobi then removed the bypass, since a standing one would let anything that authenticates as that account land on dev past CI, and asked for releases the AI maintainers can trigger without it. A release now takes the path every change takes (#19653):

  1. publish.mjs prepare opens the release pull request, and dev's required checks and CodeQL run on it.
  2. Tobi merges it: the one human step.
  3. publish.mjs finish squashes the merge onto main and creates the GitHub release.

Re-running a command resumes a phase that stopped, under three rules:

  • An unknown never passes for an absence. A failed read used to become an empty list, false or a clean tree, so a 403 on the rules or a failed git status could have shown a green gate. A proven absence, such as a recognised 404 or an empty ref, is now kept apart from a failed observation, which stops the phase.
  • A ref is reused only when it belongs to this release. An open release pull request with no local branch, or a release tag whose commit is unknown, now refuses instead of reading as waiting or released.
  • 13.2.0 itself is refused, because it was published outside this path.

Honest bounds: the path is proven against a scripted command boundary and a live rehearsal against the real rules, run as an account that cannot bypass them. 13.3.0's own cut is its first real run (#19652), and these notes claim a pull-request-gated cut only after finish, npm and neomjs.com have verified it. finish pushes the squash with --no-verify on purpose: the squash flattens every reviewed change since the last release into one commit, which the local pre-push check would reject by construction.

The end-to-end retry bridge: until 2026-10-25, a failing end-to-end test gets one retry in a fresh worker. Every run's receipt names each retried test, and the bridge expires on that date without anyone editing it (#19682). Each quarantined test names an owner and an exit, and goes stale after seven days.


📦 Full Changelog

149 pull requests merged in neomjs/neo and 148 issues closed (142 as completed) between 13.2.0's release at 2026-10-11 22:37Z and dev at bb9c8742a3, counted at 2026-10-21 10:12Z with GitHub search: is:pr is:merged merged:>=2026-10-11T22:37:33Z and is:issue is:closed closed:>=2026-10-11T22:37:33Z (reason:completed for the 142).

Content-sized rows (13)

Grid header menus (13)

Grid search toolbar (2)

The release path (1)

End-to-end tier and test fixtures (20)

  • #19667 ci(e2e): run the Brain-gated tier on dev pushes as a measured burn-in · @neo-opus-grace
  • #19676 ci(e2e): the Brain-gated burn-in declares its hosted gaps and runs on ten shards · @neo-opus-grace
  • #19683 test(e2e): headless Chrome gets a 2560x1440 screen, so the multi-window journeys' popups are not refused or clamped · @neo-opus-grace
  • #19682 ci(e2e): the Brain-gated burn-in retries once until 2026-10-25, naming every retried test · @neo-opus-grace
  • #19686 ci(e2e): each Brain burn-in shard writes its receipt to its log as well as its job summary · @neo-opus-grace
  • #19705 feat(ci): run the Brain-gated e2e tier on every Engine PR, full or smoke by the shared classifier · @neo-opus-grace
  • #19715 feat(test): connectToApp polls the page's App Worker id and never binds by appName · @neo-opus-grace
  • #19719 test(e2e): ReclaimEmbodiment :148 leaves the Brain quarantine · @neo-opus-grace
  • #19722 test(e2e): ReclaimEmbodiment :148 returns to the Brain quarantine · @neo-opus-grace
  • #19702 test(e2e): DockSplitterZoneIdNL selects the restored dock's splitter only after its refresh settles · @neo-opus-grace
  • #19679 test(e2e): ButtonBaseNL's configuration tests wait for the panel's text field instead of 500 ms · @neo-opus-grace
  • #19654 feat(test): capture grid navigation failure state · @neo-gpt-sophie
  • #19771 test(workstation): two NL witnesses prove their gesture by its outcome and name their own probe misses · @neo-opus-grace
  • #19776 test(workstation): scene 2 reaches its native return again · @neo-opus-vega
  • #19781 test(grid): AnimatedRowSorting waits for its plugin before it sorts or filters · @neo-opus-ada
  • #19838 fix(test): the neuralLink fixture stops the Bridge its worker spawned · @neo-opus-grace
  • #19844 ci(e2e): the engine tier runs as four shards, each back under the five-minute step · @neo-opus-grace
  • #19873 test(e2e): the pop-out hover-sync witness judges a scroll that opens its own burst · @neo-opus-grace
  • #19894 test(e2e): the rail-reveal witness brackets the overlay's own signal and leaves quarantine · @neo-opus-grace
  • #19906 test(e2e): the showcase dwell waits for the driver's indicator gate and leaves quarantine · @neo-opus-grace

Engine fixes (40)

Apps, examples and the Portal (16)

  • #19731 feat(workstation): restore the film saved window arrangement · @neo-gpt-euclid
  • #19700 fix(realworld): the API singletons share one stored-token promise instead of a static ready event no class provides · @neo-opus-grace
  • #19641 fix(portal): feature Workstation with current covers · @neo-gpt-sophie
  • #19812 feat(workstation): a timed-out stack return names the claim surfaces it crossed · @neo-opus-vega
  • #19817 feat(workstation): a timed-out stack return carries the coordinator's claim-pass record · @neo-opus-vega
  • #19883 fix(realworld): both apps call the RealWorld project's hosted API at api.realworld.show · @neo-opus-grace
  • #19892 fix(realworld2): its addon list keeps Navigator, so list views mount once · @neo-opus-grace
  • #19880 fix(workstation): spec tours queue their projections behind an in-flight one · @neo-opus-grace
  • #19905 fix(workstation): tab-chrome and shell receipts read the live shell during a projection swap · @neo-opus-grace
  • #19912 fix(portal): a #N inside a Markdown link stays the link's, and an unknown ticket id loads no chunks · @neo-opus-ada
  • #19935 fix(realworld): RealWorld v1 renders styled, shows one sidebar, and its article page renders · @neo-opus-grace
  • #19937 fix(portal): rewrite relative blog links to blog and learn routes · @neo-gemini-maryam
  • #19938 fix(examples): load the tablePerformance rows through the table store · @bercedev
  • #19940 fix(portal): theme switch works on the first click after a fresh load · @abhinav-rb
  • #19948 feat(portal): the example galleries list the grid issues showcase, behind its 13.3 cover · @neo-opus-grace
  • #19924 fix(examples): give the OpenStreetMaps example's maxZoom as a class field · @bercedev

Docs, roadmap and blog (25)

Harness and dependencies (19)

Who built it: pull requests by the AI maintainers Grace, Ada, Euclid, Vega, Sophie, Emmy, Mnemosyne, Clio, Julia and Maryam, reviewed across the Claude, GPT and Gemini families, with Tobi (@tobiu) steering and merging. Thank you to @sloemo01, @taha-yasin-saad, @tnalbant, @sunred-liu, @MFA-G, @bercedev, @Uddoo and @abhinav-rb, the window's contributors from outside the team.

All changes delivered in 1 atomic commit: 282e7ef