Repository navigation
Neo.mjs v13.3.0 Release Notes
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.
📋 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:
- Content-sized rows (#19658).
contentSizedRows: truesizes 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. - Header menus (#19643).
columnMenu: truegives 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. - 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.
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.
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 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
columnMenurenders 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
afterSetMountedthrew, 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
AnimateRowsplugin 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
bodyandheaderToolbarconfigs: 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 tosortable,showHeaderFiltersoruseTriStateSortingreaches the locked headers (#19798, #19799). - Three sorting fixes. A sort by a field named
indexemptied 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 tosortable: falsealso 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 readsaria-sortoff 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.Bufferedin 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, asgrid.Viewalready 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, andexamples/grid/nestedRecordFieldsshowed 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
beforeSetthat 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 atapi.realworld.show(#19882, #19883). RealWorld2 also mounts its views once again: its addon list had droppedNavigator, 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:
- Scroll. Each row is as tall as its title or summary needs, and the issue number and state stay aligned in their locked columns.
- 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.
- 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.
- 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.
- 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:
- Pull a pane's tab past the browser window's edge: the pane becomes a window of its own mid-gesture.
- Carry it back over the main window. Main previews where it will dock, and it lands there.
- 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):
publish.mjs prepareopens the release pull request, anddev's required checks and CodeQL run on it.- Tobi merges it: the one human step.
publish.mjs finishsquashes the merge ontomainand 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,
falseor a clean tree, so a 403 on the rules or a failedgit statuscould 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)
- #19668 feat(grid): opt-in content-sized rows read one row geometry per view · @neo-opus-ada
- #19672 feat(grid): content-sized rows measure the wrapped text of wrapText columns · @neo-opus-ada
- #19677 feat(grid): AnimateRows stands aside in content-sized mode · @neo-opus-ada
- #19674 feat(grid): content-sized height corrections keep the visible row still · @neo-opus-ada
- #19695 fix(grid): content-sized rows read a cell only once the DOM shows it · @neo-opus-ada
- #19699 fix(grid): a row reached by keyboard keeps its edge while the rows around it are measured · @neo-opus-ada
- #19688 feat(grid): premeasure content-sized rows in native layout · @neo-gpt-euclid
- #19704 feat(grid): a 10k wrapped-text example proves content-sized rows end to end · @neo-opus-ada
- #19761 feat(grid): a grid of Neo's own issues shows content-sized rows and header menus · @neo-opus-ada
- #19807 fix(examples): the issues showcase's fixed rows show one cut line of text · @neo-opus-ada
- #19833 feat(grid): tree columns wrap their labels in content-sized grids · @neo-opus-grace
- #19834 test(grid): pin content-sized rows sized by a locked column, on both sides · @neo-opus-ada
- #19911 fix(grid): switching row modes keeps the row in view · @neo-opus-ada
Grid header menus (13)
- #19673 feat(data): isolate column filters and count typed values · @neo-gpt-euclid
- #19709 feat(list): buffered value picker with counts and logical keys · @neo-gpt-euclid
- #19711 feat(grid): add shared column value menus · @neo-gpt-euclid
- #19724 fix(grid): format header value menu labels · @neo-gpt-euclid
- #19750 feat(grid): the bigData example's Settings tab toggles the header column menus · @neo-opus-ada
- #19758 fix(grid): header menus keep rows and cells under their headers · @neo-opus-ada
- #19788 fix(grid): keep inline filter clicks from sorting · @neo-gpt-emmy
- #19795 fix(table): keep header filter clicks from sorting · @neo-opus-grace
- #19799 fix(grid): header-wide state reaches the locked header regions · @neo-opus-grace
- #19805 feat(grid): headers show when their column is filtered · @neo-opus-ada
- #19884 fix(list): announce a single match as "1 match" in the ValuePicker aria-label · @tnalbant
- #19915 fix(grid): the header-menu arrow takes the header's glyph colour · @MFA-G
- #19929 fix(grid): the column filter list draws its focus ring inside the row and its rows on the menu · @neo-opus-ada
Grid search toolbar (2)
- #19796 feat(data): a Store-wide record search contribution with declared read sets · @neo-opus-grace
- #19804 feat(grid): add optional shared record search toolbar · @neo-gpt-emmy
The release path (1)
- #19653 feat(release): release through a PR, squash on finish, resume on re-run · @neo-opus-grace
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)
- #19689 fix(component): an unmounted component never receives a settled mountedPromise, so updateVdom cannot loop on it · @neo-opus-grace
- #19701 fix(tab): Overflow.project() treats a destroyed owner or plugin as teardown, not a live-owner defect · @neo-opus-grace
- #19703 fix(dock): a Group projection stands down once a newer commit adopted its participant's next document · @neo-opus-grace
- #19708 fix(dock): fully land CSS transitions before resize · @neo-gpt-euclid
- #19728 fix(worker): identify in-flight window departures · @neo-gpt-euclid
- #19734 Guard SharedCanvas renderer admission after failed module loads · @neo-gpt-euclid
- #19738 fix(list): preserve single-motion component sorting · @neo-gpt-euclid
- #19752 feat(data): identify sort loads for animated lists · @neo-gpt-euclid
- #19690 fix(grid): support Chromium 156 in the dependency update · @dependabot, @neo-gpt-euclid
- #19710 test(draggable): pin the switchItems gap math for the header toolbars · @sloemo01
- #19763 fix(list): a silent pooled re-bind ships dense, so a merged row update cannot prune it · @neo-opus-grace
- #19767 fix(drag): exclude disconnected native source windows · @neo-gpt-sophie
- #19774 fix(grid): AnimateRows moves pooled rows by record and transitions only while they move · @neo-opus-ada
- #19779 fix(dock): a committed native drop retires its proxy · @neo-opus-vega
- #19777 fix(grid): locked bodies and header toolbars clone their center instances · @neo-opus-ada
- #19797 feat(dock): a returned vessel closes when main adopts, not when its motion settles · @neo-opus-vega
- #19802 fix(collection): a sort by a field named index keeps every item · @neo-opus-grace
- #19803 fix(grid): a sortable: false header never sorts on click · @neo-opus-grace
- #19821 fix(grid): a destroyed grid leaves no addon registration behind · @neo-opus-grace
- #19824 fix(grid): a grid built with locked columns syncs row hover from its first mount · @neo-opus-grace
- #19831 fix(component): a functional component destroyed mid-update releases its render flight · @neo-opus-ada
- #19840 fix(grid): a column's renderer string resolves to its view controller's method · @neo-opus-grace
- #19842 fix(dock): a native title-bar drop starts only when the window's origin moves · @neo-opus-grace
- #19852 fix(core): a beforeSet that throws leaves the previous value · @neo-opus-ada
- #19867 fix(dock): a popup carried back over main lands where main previewed it, and a second titlebar return docks · @neo-opus-grace
- #19870 fix(dock): a pane returned by its title bar right after another one docks · @neo-opus-grace
- #19874 test(core): pin how IdGenerator names every instance · @taha-yasin-saad
- #19871 feat(dock): a drag that does not land where its preview showed reports itself · @neo-opus-grace
- #19890 fix(grid): the animated sorting example's Transition Duration field reaches every body · @neo-opus-ada
- #19895 fix(dock): a reordered group keeps its tab-overflow control · @neo-opus-ada
- #19907 fix(collection): a filter change keeps the active sort · @neo-opus-ada
- #19908 fix(grid): sorted headers announce their direction through aria-sort · @sunred-liu
- #19914 fix(grid): only a header carrying aria-sort clears and updates, witnessed in the browser · @neo-opus-ada
- #19918 fix(worker): a window that closes mid-boot frees its tear-out slot and its theme loads · @neo-opus-grace
- #19921 fix(vdom): a mount hook that throws leaves its siblings synced · @neo-opus-grace
- #19923 fix(layout): the form layout marks a legend item instead of throwing · @bercedev
- #19927 fix(vdom): rebind node on changeNodeName in DeltaUpdates.updateNode · @neo-gemini-julia
- #19936 fix(vdom): guard parent merge and collection against windowId mismatch · @neo-gemini-maryam
- #19939 fix(container): an insert before the layout exists leaves the child attributes to createItems · @bercedev
- #19945 fix(date): defer day-view rendering until bound inputs arrive · @Uddoo
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)
- #19644 docs(roadmap): Engine 13.3 replaces the shipped 13.2 section · @neo-opus-ada
- #19660 docs(roadmap): cornerstone 1 points at the content-sized rows epic · @neo-opus-ada
- #19639 docs(blog): the multi-window post draws the update triangle, opens for cold readers and leads with the film · @neo-opus-grace
- #19648 docs(blog): the multi-window post on GitHub takes its Medium title, subtitle, disclosure and cover label · @neo-opus-grace
- #19726 docs(agentos): define Fleet update continuity · @neo-gpt-sophie
- #19736 fix(agentos): retire stale Antigravity instructions · @neo-gpt-sophie
- #19740 docs(readme): add Julia to maintainer roster · @neo-gemini-julia
- #19748 docs(release): the 13.3.0 notes' iteration 1, from the stewards' deposits · @neo-opus-ada
- #19754 docs(release): the 13.3.0 notes' iteration 2, the grid frames and the merges since · @neo-opus-ada
- #19760 docs(release): the 13.3.0 notes' iteration 3, the showcase frames and the designation · @neo-opus-ada
- #19769 docs(release): the 13.3.0 notes' iteration 4, for readers new to Neo · @neo-opus-ada
- #19783 docs(release): the 13.3.0 notes' iteration 5, animated row sorting and locked columns · @neo-opus-ada
- #19809 docs(blog): where an agent institution sits — the landscape, with receipts · @neo-fable-mnemosyne
- #19814 docs(release): the 13.3.0 notes' iteration 6, filtered headers and the week's grid fixes · @neo-opus-ada
- #19819 docs(adr): ADR 0034 amendment, v1 target platform and the OS seam · @neo-fable-clio
- #19825 docs(release): the 13.3.0 notes' iteration 7, the record search toolbar and the fixes since · @neo-opus-ada
- #19826 docs(identity): Neo's identity after D#19789: OD-5, the vision line, the category label and the staged arc · @neo-opus-ada
- #19836 docs(adr): ADR-0034 §2.3 item 10 lets the setup card request a fresh run · @neo-fable-mnemosyne
- #19846 docs(readme): add Maryam to maintainer roster · @neo-gemini-maryam
- #19850 docs(blog): lead with preserved panel state · @neo-gpt-emmy
- #19854 fix(docs): the front door's fingerprint guard returns to neo, and the front door declares its true size · @neo-opus-ada
- #19858 docs(adr): ADR 0034 amendment, an unproved seat loses authority, not the update · @neo-opus-grace
- #19862 docs(adr): the five Fleet Manager records leave pointers in the Engine · @neo-opus-grace
- #19888 docs(readme): link maintainers by their current GitHub logins · @neo-opus-ada
- #19925 docs(learn): point dead blog and guide links at where the files live now · @bercedev
Harness and dependencies (19)
- #19650 chore(harness): allow docker exec, cp and compose on the plane containers · @neo-opus-vega
- #19743 chore(settings): a Claude seat cannot start a second session of itself · @neo-opus-vega
- #19691 build(deps-dev): bump neo-agent-skills from 0.1.39 to 0.1.41 · @dependabot
- #19696 build(deps): bump neomjs/neo-agent-skills/.github/workflows/reusable-pr-baseline.yml from 0.1.30 to 0.1.31 in the actions group · @dependabot
- #19737 build(deps): bump the actions group with 2 updates · @dependabot
- #19746 build(deps): bump the actions group with 2 updates · @dependabot
- #19785 fix(deps-dev): bump the all-deps group with 3 updates · @dependabot
- #19848 feat(hooks): a Bash guard refuses gh edit --body-file - fed by an unchecked pipe · @neo-opus-vega
- #19860 fix(deps-dev): bump neo-agent-skills from 0.1.41 to 0.1.51 · @dependabot
- #19861 fix(deps): bump the actions group with 2 updates · @dependabot
- #19864 chore(deps-dev): Playwright 1.64.1, and the launch profiles repeat its new disabled feature · @neo-opus-ada
- #19865 fix(deps-dev): bump the all-deps group across 1 directory with 4 updates · @dependabot
- #19875 fix(deps-dev): bump cssnano from 9.8.0 to 9.9.0 in the all-deps group · @dependabot
- #19876 fix(deps-dev): bump neo-agent-skills from 0.1.51 to 0.1.54 · @dependabot
- #19877 fix(deps): bump the actions group with 2 updates · @dependabot
- #19941 fix(deps-dev): bump neo-agent-skills from 0.1.54 to 0.1.55 · @dependabot
- #19942 fix(deps): bump the actions group with 2 updates · @dependabot
- #19943 fix(deps-dev): bump markdown-it from 14.3.2 to 14.3.3 · @dependabot
- #19944 fix(deps): bump launch-editor from 2.14.1 to 2.14.2 · @dependabot
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




