Skip to content

1.17.0 - #68

Merged
ONyklicek merged 30 commits into
1.xfrom
1.17.0
Aug 10, 2026
Merged

1.17.0#68
ONyklicek merged 30 commits into
1.xfrom
1.17.0

Conversation

@ONyklicek

Copy link
Copy Markdown
Owner

No description provided.

ONyklicek and others added 30 commits August 7, 2026 18:11
A table with reorderable() or columnReorderable() stopped responding to its
own search box: the server filtered correctly and sent the rows back, and the
client threw the response away. No error, nothing in the console.

The drag controller's two morph hooks asked "is any input inside the table
focused?" — of every node from the sortable wrapper down. skip() takes the
whole subtree with it and contains() is inclusive, so the answer came back yes
at the wrapper itself and the morph never entered the table. The search box is
an input inside the table, which made it the one control guaranteed to silence
the render it had just asked for.

Both guards now name what they protect: the cell being edited, found by the
[data-record-key][data-column-name] pair the editable columns render — the
selector wireTableLive already reads in busy() — and skipped only when the
morph is at that exact node. The drag guard is unchanged; it has moved rows
the server knows nothing about.

Two things found on the way, in the same hooks:

- morph.updated fires per patched node, so every morph tore down and rebuilt
  both SortableJS instances a hundred times over. Coalesced to one setup().
- The hooks were registered from init(), and Livewire.hook() has no off
  switch, so every re-init stacked another pair — a second table, a
  wire:navigate, a table in a lazily loaded modal — each copy still answering
  for a component that no longer existed. Installed once per document now,
  with live controllers in a map keyed by their wrapper element. Keyed by the
  element because Alpine calls destroy() with a merge proxy of the scope, not
  the instance init() saw, so removing by identity removes nothing.

verify-sortable-morph.mjs (25/25) drives all of it against a new
/previews/sortable-morph, and fails on the old bundle exactly as reported:
6 rows in, 6 rows out, the morph stopping one node inside the wrapper. Pest
covers the guard's shape, the dist not drifting from source, and the
cross-package contract that an editable cell really renders that attribute
pair — it lives in wire-table and could otherwise rename itself out from
under the selector.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The editors titled their toolbars with bare __('Bold') keys, which only an
app-level translation file can ever answer. A Czech app therefore shipped a
fully translated form with an English editor bolted into the middle of it,
and the two strings that are read inside the JS bundle — the link and image
prompt() titles — could not be translated at all.

All of it now resolves from wire-forms::fields.editor.*, en and cs, and the
group is named editor rather than tiptap because RichEditor and
MarkdownEditor title their buttons from the very same keys: one vocabulary
for all three, so they read alike in every locale and a reworded button is
reworded once. The prompt titles are resolved in PHP and handed to the
editor through its Alpine config, so a locale change reaches strings that
live inside the bundle. Headings read as Heading 2 / Nadpis 2; the glyph on
the button stays H1/H2/H3 in every locale, being a symbol rather than a word.

RichEditor's link prompt moved to @js(), which hex-escapes both quote
characters. The old prompt('{{ __('Enter URL') }}') renders an apostrophe as
&#39;, which decodes back to a quote and closes the JS string — and with it
the x-data attribute around it — so any wording containing one would have
killed the field.

A starting document is the canonical ->default() and not a second
editor-only method. The form runtime already seeds it into the state bag;
the field now also hands it to the editor, which applies it when the bound
value is empty and pushes the parsed document back into Livewire, so a host
that never seeded — a null column, a hand-bound property — still opens on
the template, and saving an untouched form stores it rather than nothing.
The default is markup, so it arrives formatted. Under outputJson() it may be
a JSON document string or the same HTML, which is where the old code dropped
it: a non-JSON value was parsed with a catch returning {} and became an empty
editor. Re-opening a document the user deliberately cleared does not bring
the default back — an emptied editor stores <p></p>, not ''.

Two bugs found while switching MarkdownEditor over, both from its Alpine
component being written inline as an x-data attribute, where the HTML parser
reads the code before JavaScript does:

- A raw double quote ends the attribute wherever it appears, so the regex
  literal /\"/g truncated the component mid-function. Alpine got an
  expression ending in .replace(/\ and threw "Invalid regular expression:
  missing /" — as a warning, after which nothing worked: no Write/Preview
  switch, no preview, no entangle, no toolbar insertion. The page source
  still looked complete, which is why no test saw it.
- An entity is decoded, so '&amp;' written once arrived as '&' and the
  preview's sanitiser read replace(& with &). The HTML neutralisation the
  comment above it promised was a no-op on all four characters, and raw
  markup reached x-html unescaped.

Every quote in that expression is an entity now and every replacement is
written twice over; the rendered output is byte-identical, and typing
<img src=x onerror=…> shows as text rather than making a request.

Verified where each thing actually lives. Pest covers the vocabulary, the
en/cs parity, the default reaching both the state bag and the editor config,
and — for the x-data pair — the attribute as the parser decodes it rather
than as the source reads, since the source looked right in both cases.
verify-tiptap-split.mjs (14/14) now also reads the seeded default out of
Livewire's state against a new /previews/field-tiptap-default, and a new
verify-markdown-editor.mjs (7/7) drives the two older editors, which had no
browser coverage at all — which is how this survived.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pickers' triggers carried an unconditional readonly, so the calendar
and the steppers were the only route to a value: an appointment three
years out cost 36 clicks on the month arrow, and at TimePicker's
30-minute stride 08:07 could not be expressed at all.

The box now takes text, read back through the same format it displays.
The parser is the inverse of the formatter beside it — it takes the order
of the parts from the format and fills each place with the next run of
digits — so it lives once, in a shared partial, with a per-view
applyTyped() for what a set of numbers means there. A parser duplicated
across two views and drifting by one token stores the wrong day without
saying so.

Nothing half-read reaches the state. 31 February is refused rather than
rolled into March the way new Date() would, and so is an hour past 23 and
a day the bounds or disabledDates exclude — the gate the calendar cells
already go through.

Two more things were wrong in the same markup. readOnly() was
decorative: neither view read it, and the panel wrote to the state
regardless. And nothing opened the panel from the keyboard — the trigger
listened for clicks only and the chevron was a pointer-events-none div.
The chevron is a button now, the input opens on ArrowDown, and click
opens rather than toggles, since clicking to place the caret must not
close the panel. Making the chevron a real toggle needed one more fix:
Alpine runs click.outside in the capture phase, so it closed the panel a
beat before the button's own handler ran.

typeable(false) is the way back out, as CanBeTyped next to CanBeReadOnly
— it closes the keyboard and leaves the widget, where readOnly() closes
both. DateFilter's panel inherits all of it.

Browser-verified by verify-datepicker-typing.mjs (21/21).
Route delivery only works when the request reaches PHP, and a very common
nginx layout answers `.js` from a `try_files $uri =404` block that never
forwards it — the same block 404s Livewire's own `/livewire/livewire.js`.
On shared hosting that block is usually not the app's to change, so the
bundles now come out of `public/vendor/<package>` as real files, put there
by `PublishedAssets` in laravel-package-toolkit 2.3.0 on the first render.
Nothing to run, nothing to configure. `Js` falls back to the package route
where `public/` cannot be written, and `AssetManager` warns, unconditionally,
about a copy the mirror could not refresh.

Two defects found while testing it:

- `renderScripts()` judged staleness before rendering the tags, but resolving
  a URL is what runs the mirror — so the first request after an upgrade warned
  about a state the next line repaired. Tags first now.
- `packages/sortable/phpunit.xml` declared no Feature suite, so
  `composer test:sortable` silently skipped 47 of its 87 tests. Table's
  Benchmarks were likewise absent from the root config that CI runs.

Also drops the dead `tests/Unit/ExampleTest.php`, points the suite's
`public_path()` at a throwaway directory so the mirror writes nothing into
the repo, and narrows the CI matrix to the Laravel 12/13 floor the toolkit
requires.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The render-cost model covered the server — view renders per row — and had
nothing to say about what the browser pays for the result. Measured on a
40-row preview, the morph took ~102 ms, longer than the round-trip that
carried the HTML, and 79 % of the nodes it walked held no content at all:
whitespace text nodes and Livewire's own morph markers.

TablePayloadFuseTest is the client-side counterpart to the render-count
fuse. It budgets bytes, whitespace runs and comments per row as a slope, so
the fixed chrome drops out of the number. The mechanic worth knowing is that
a run of whitespace between two tags is ONE text node however short it is —
shortening indentation saves bytes but not nodes.

The copy affordance was an Alpine component per cell: an x-data, an inline
handler, two template x-if icons and a transition span, at 2042 bytes and 11
whitespace nodes each, so a 500-cell table shipped about a megabyte of it.
It is now a plain <button data-copy> plus one delegated listener in a new
record-copy bundle and one feedback pill per page — 943 B and 2 nodes.

Foundation\View\Skeleton makes the cell skeleton canonical and gives it more
than one hole. actionUrl(), copyable(), description(Closure) and
icon(Closure) used to drop a column back onto the per-cell render at 11-22x
the cost of a splice, and 3.5x the whole-table mount when every column
carried one. Each is a slot now. What keeps this safe where the inline-edit
skeleton was not is one slot, one position, one encoding; delegating the
copy button is what removed the last @js() and made the copy value qualify.

Finally the row itself: the <td> opening tag is assembled once per column
and the <tr> compiled once per table, because every condition on them is a
property of the table rather than of a record. 4214 to 1823 bytes per row.

Byte-identity against the classic render is guarded across a hostile matrix
of contents and record keys, and the row rewrite was proven against a golden
master of five table configurations — identical in structure and in every
attribute value, whitespace aside.
`{package}:install` publishes config, migrations and views, and only public/
had a path override — so everything else landed in the testbench skeleton and
stayed there. Both leftovers broke things silently and in different ways.

Views: Laravel resolves a published view before the package one, so once a run
had happened, the whole suite and the preview server rendered a frozen snapshot
instead of packages/*/resources/views. Editing a Blade file then did nothing at
all, with no error and nothing to clear.

Migrations: each run published another timestamped copy — 75 files had piled up
— and from the second run on, migrate:fresh dies on "table already exists" and
leaves the workbench database half-migrated and unseeded, so every CDP driver
runs against an empty preview and fails for reasons that look like code bugs.

The test now snapshots the three directories before the installs and removes
whatever is new afterwards, rather than naming files: the published migration
names carry a timestamp, and a snapshot keeps working when a package adds one.
Continues the "static once, dynamic per row" work from the <td> and <tr>
(§8d) through the four pieces of the row loop that were still being laid out
by Blade for every record. Each is compiled once — from its own partial, with
a Skeleton slot where the per-record value goes — and spliced per row.

Measured per row, before -> after:

  selection cell         2251 B, 10 whitespace nodes -> 1172 B, 0
  context-menu panel     1659 B, 14                  ->  982 B, 6
  sub-row expander cell  1044 B,  8, +1 view render  ->  760 B, 1, no render
  group header            318 B,  6, +1 render/group ->  222 B, 0, one/table
  group subtotal         1674 B, 28                  -> 1222 B, 4
  expanded sub-rows      3019 B, 37                  -> 2113 B, 8
  actions cell           1470 B, 16                  -> 1155 B, 10

A whitespace run between two tags is one DOM text node, and the morph walks
every one of them on every commit, so the indentation in a partial emitted
once per row is not free. What is left in the panels is their real per-record
content.

The markup stays in Blade. A first attempt built the selection cell as a PHP
string and was byte-identical, and it was still wrong: it destroys the
vendor:publish override point and hides markup from every Blade tool. That is
now written into AI_CODING_STANDARD.md as binding — always Htmlable, always
Blade — with the mechanics that make the Blade version just as cheap.

Two things are deliberately NOT done, both because measuring said so:

  - The `@if`s stay, even where the string they guard is already empty.
    Removing the one around the context-menu panel saved 55 B/row and broke
    the column reorder: morphdom needs that block boundary when a row's
    children change under it. Both fuses stayed green through the breakage;
    only verify-gesture-lab caught it.
  - The actions cell keeps its `@foreach`. An action can be non-executable
    for one record and not the next, so its button list genuinely changes —
    exactly what those markers are for.

A marker can also go missing without deleting anything: Livewire matches
directives with a regex and skips what it cannot classify, and gluing one onto
a Blade comment (`--}}@if(...)`) loses the opening marker while the closing one
is still emitted. TablePayloadFuseTest now asserts BLOCK/ENDBLOCK balance
across nine table shapes, sabotage-verified, so that whole class of Blade
authoring mistake fails a test instead of a customer's reorder.

Guarded by a golden master over five to eight configurations per change —
structurally identical every time, marker counts unchanged, and the variants
that do not use the feature byte-identical as controls.
Headless Chrome backgrounds a window it never shows and throttles the
renderer, and the drivers were reading that as product bugs. A full sweep sat
at 56/64; it is 64/64 now, with no change to the library.

It takes two fixes, and each covers a failure the other does not.

Polling instead of sleep(). A CDP evaluation wakes the JS thread, so waiting
on the condition both waits and keeps the page running. This is what anything
awaited needs: a record-action modal opens ~700 ms after the click on a
settled page, yet a sleep(1600) reported it missing on every run.
verify-gesture-lab went 22/28 -> 28/28 on that alone, and
verify-selection-gestures — which slept 80 s of its own 180 s cap, and so was
regularly killed mid-run — went 75/77 in ~150 s to 77/77 in 75 s.

The anti-throttling flags, in all 64 drivers (55 spawn Chrome themselves, 9
share scripts/lib/cdp.mjs), because polling does not wake requestAnimationFrame.
Without them an Alpine transition never finishes: a modal already told to close
sits there reporting `show: false` and `display: block` at the same time, and a
teleported panel never finishes opening. That was every *-sheet driver,
select-floating and five checks in record-active-row.

Applying one without the other is worse than useless — flags alone flipped
verify-gestures-off red, polling alone left "Escape closes the shortcut help"
failing.

Three of the failures were real driver bugs the throttling had been masking.
verify-gesture-lab and verify-spa-navigate simulated a column drag with
`document.querySelectorAll('thead th')`, which spans both header rows on a
table that has a column-filter row — so "move after the last th" dropped the
dragged column INTO the filter row, somewhere no real drag can put it, and the
controller then read the new order off the row it is bound to, which no longer
held that column. verify-spa-navigate also read the header order from
`data-sortable-column`, an attribute the sortable controller adds and every
morph wipes, so straight after a reorder it legitimately came back empty.
`stackedOnMobile()` renders every record twice: the desktop <tr>s and the cards
are both in the document and CSS decides which one is seen. So the card's
layout is emitted per row on every commit, at whatever width the reader is at —
and it was the single biggest per-row cost in the table, larger than everything
the <td>, <tr>, selection cell, context menu, expander and sibling rows saved
put together.

Measured per row: 4391 B, 36 whitespace nodes and 22 morph markers, which is
225% on top of the row it duplicates. A stackedOnMobile() table shipped 3.25x
the payload of the same table without it.

Closing up the card body — the part inside the @forelse; the select-all bar and
the summary footer are O(1) and untouched — takes it to 2830 B and 10 nodes,
markers unchanged. On a five-row golden master that is -10.5% of the whole
page, up to -12.8% on a plain stacked table.

A run of whitespace between two tags is one DOM text node and the morph walks
every one of them, so indentation in markup emitted per row is not free.
Whitespace between attributes is, so the attributes stay laid out and every
conditional stays where it was.

It stays inline rather than moving to a partial: the card is per-record, so a
partial would add a view render per row — the very thing the render fuse is
there to catch.

Guarded by a golden master over nine configurations (not-stacked, plain,
selectable, actions, collapsed-actions, record-url, subrows, summaries, full):
all structurally identical, marker counts unchanged in every one, and
not-stacked byte-for-byte as the control. The payload fuse now pins the card at
<2950 B, <=10 nodes and exactly 22 markers.

What this does NOT change is that the card is still a second full server-side
rendering of the page. Making it conditional is an API decision of the same
kind as ActionGroup::lazyMenu() — it trades a no-JS, no-latency, always-present
DOM for half the payload — so it is written up in the plan rather than slipped
in here.
`PublishedAssets::flush()` landed in laravel-package-toolkit 2.3.1, and the
constraint asked only for ^2.3.0 — so the Octane RequestTerminated hook probed
for the method before calling it, with a comment saying to drop the guard once
the constraint required the release that carries it. It does now (^2.4.0), so
this is that.

It also silences the one PHPStan error in the project: against the installed
2.4.0 the method_exists() call is provably always true. That error was there
before this branch and never showed up locally, because a stale result cache in
build/phpstan was answering for the file — worth knowing the next time
`composer analyse` looks suspiciously quiet.
An action button was two `view()->render()` calls — the button view and the
content partial it includes — for every action on every row. Measured at 2.06
renders per action per row, so three actions over twelve rows was 72 view
renders, and it was the last N×View left in the render engine.

Action::render() compiles one skeleton per SHAPE now and splices. What makes
this a single-slot case is that the click expression is the only per-record
value that reaches the markup — `recordKey` is in the render array but no view
echoes it — and it lands in `wire:click` and up to three `wire:target`s, every
one of them a Blade `{{ }}` inside an attribute. One slot, one position kind,
one encoding.

Correctness does not rest on guessing which actions are "simple". The shape key
is the whole render array minus the two spliced fields, plus the three methods
the view calls on the action directly (isHidden, getLabel, getName), so an
action whose label, colour, icon, tooltip, url, disabled state or extra
attributes vary by record lands on a different skeleton and is rendered for
itself. ActionButtonSkeletonTest measures that split rather than assuming it: a
disabled(fn) action over twenty rows compiles exactly two.

2.06 -> 0 renders per action per row. The table's render fuse now pins a
three-action table to the same per-row slope as a table with none.

One deliberate change to the output: a compiled skeleton is trimmed, so a
button no longer carries the view file's own leading and trailing newline —
one DOM text node per button, gone. Everything else is byte-identical, and that
is measured: 21 shapes × 10 record keys chosen to break naive escaping
(quotes, ampersand, <x>, unicode, backslash, '0'), plus the no-record and
default-resolver paths, each compared against the view rendered directly.

FixedClickResolver is what lets the view be rendered once with a slot where the
click expression goes. It is internal — a host describes how its actions are
invoked with a real resolver, and this one describes nothing.
Both were built as PHP strings in the view preamble, which is what the rule
added alongside the selection cell — always Htmlable, always Blade — then
forbade. They are partials now, compiled once and spliced exactly as before, so
this costs nothing at render time and puts the markup back where a consumer can
publish it and where Blade tooling can see it.

  body-cell.blade.php      the whole <td>, balanced, with a slot for the
                           record's content — compiled once per column.
                           Byte-identical across all eight golden-master
                           configurations.

  body-row-open.blade.php  the row's opening tag only. Deliberately not a whole
                           row: the row's children each sit behind a conditional
                           whose morph markers are load-bearing, and wrapping
                           them in a slot would swallow those conditionals.

Two things about the row partial are measured, not stylistic, and both have a
comment saying so:

It is all on one line. Laid out over eight lines it cost +50 B per row — half of
what compiling the row won in the first place — because whitespace between
attributes costs no DOM node but does cost bytes, and this tag is emitted once
per row. The explanation lives in the comment block above it, where Blade strips
it.

A literal space separates each `@if` from the one before it. `@endif@if(` never
compiles: a directive preceded by a word character does not match Blade's
pattern, and it reaches the page as literal text. Same family as the `--}}@if`
trap the marker-balance fuse now guards.

The cost of that separator is a stray space inside the tag when a condition is
false: +4 B per row, no DOM node, no semantics. Everything else is identical,
verified by masking whitespace inside the <tr> tag only and comparing the eight
configurations — identical every one.
There were two implementations of the same thing. A table's copyable cell had the
good one — a plain `<button data-copy>` and one document listener for the whole
page — while an infolist's copyable entry still carried an Alpine component PER
ENTRY: its own `copied` flag, its own setTimeout, two icons toggled by x-show,
and navigator.clipboard.writeText inline in an @click. Same capability, two
answers, and the worse one sitting in the lower package.

Foundation\View\CopyButton owns it now, and owns all three parts: the markup
(partials/copy-button), the behaviour (copy.js, moved from wire-table and
registered as the wire-core::copy bundle) and the feedback pill
(partials/copy-assets). Core is the lowest layer both callers can reach, which is
where CLAUDE.md puts a shared capability and where ADR 0024 already puts assets —
each provider registers its own bundles, and core does not learn that downstream
packages exist.

Rendered once per SHAPE and spliced, like every other surface in the engine: the
value and the announcement are the only per-caller parts, and both land in one
attribute under one encoding.

The table's page does not change by a byte. It still passes its own
`wire-table::messages.copy` — a consumer has already translated that, and
reaching for core's key instead would change the page to save a parameter — and
the button's attribute order is kept, so the move is invisible in the output.
CopyButtonTest asserts that exact markup.

A bug the move surfaced: the assets partial was included once per table, but per
copyable ENTRY — so two copyable entries meant two feedback pills, and the
controller writes into the first one it finds, leaving the second dead. The pill
is @once now, with a test that includes the partial twice in a single pass
(@once resets between top-level renders, so rendering the view twice would pass
for the wrong reason).

It costs one view render per copy shape, one-off, because the button partial
compiles into its skeleton: TextColumnSkeletonTest moves from <=2 to <=3 renders
for 100 rows, which is the number that matters — it does not grow with the rows.
The card is still a second full server-side rendering of every record — 2830 B
and 10 nodes per row after §8i took a third off — and whether it should happen
at all is an API decision, not a refactor. Leaving it as one line in Still open
meant the next person would re-derive the analysis, so it is written down.

The measurement that frames it: 0.9% of a card is text, 99.1% is markup. The
duplication is chrome, not data, so reusing the row's already-rendered cell
strings buys nothing — only not emitting a second subtree helps.

What the card adds beyond restyling, which is what any answer has to keep: the
slot hierarchy MobileCard resolves, labels beside values because <thead> is
hidden, renderMobileCell()'s different content per breakpoint, its own sub-rows
and summary partials, and the one that rules out the naive answers —
getMobileRowActionsForDisplay() is a SUPERSET, the row buttons plus the
behaviour-only record actions turned into something a finger can reach.

Four options with their costs, and why restyling the rows with CSS looks like
the optimum: the mechanism is already here, in the active-row marker's
`[&>td:first-of-type]:before:…` — arbitrary descendant variants on the row's own
class, paid once per row and styling every cell in it.

Also written down: why it is not simply "do it". The point of that change is to
change the markup, so neither the payload fuse nor a golden master can guard it
— it needs CDP screenshots at two widths — and it permanently constrains what a
card may look like. And that there is no cheaper interim step: the card already
renders inline at zero view renders, so a skeleton would remove renders it does
not have and no bytes.
Row reorder mode fetched the bare base query, so search, filters,
pagination and the column sort all fell away on entry. Dropping
pagination and the sort is right — a drag needs the list whole, and the
sequence on screen is the sequence a drop writes back. Dropping search
and filters was not: it left the search box, the filter inputs and the
per-page select rendered and inert, and `alwaysReorderable()` tables
never leave reorder mode, so theirs were inert for good.

The reason they were ever bypassed is the write, not the read. The
client reports each row's new position as `1..n`, so a drop over a
narrowed list would stamp `1, 2, 3` onto the visible rows and shove
every hidden one down the table — `paginatedWhileReordering()` already
had exactly that bug, a tidy-up on page two rewriting page one.

So the write goes first. A drop no longer numbers the rows it was
handed: it collects the order values those rows already hold, sorts them
ascending, and redistributes them in the new visual order. Rows outside
the drag keep their slots, and a column with gaps keeps its gaps. Only a
null or constant column, having nothing to redistribute, falls back to
the client's positions. The lookup runs through the table's base query,
so a key outside the scoped set brings no slot with it and drops out of
the write — the IDOR guard still holds.

With that in place the fetch can go through `WithTable::buildTableQuery()`
and merely `reorder()` the result, so what a user narrows to is what
they can drag.

The drag is browser-only — the payload comes from SortableJS reading the
DOM in `onEnd`, and the answer arrives through a morph the drag
controller may skip — so a driver drives a real drop over the new
`sortable-everything` preview: row handles, draggable headers, search,
pagination and a 3s poll on one table. Against the old write it fails
exactly as reported, a searched drag shoving a third row onto page two
and a page-two drag jumping a row onto page one.

Also: `sortable-columns` and `sortable-morph` join the preview index,
which they had been missing from since they were added, and every
SortablePreview variant now persists column order under its own key
instead of sharing one and deciding what the next fixture opens with.
All five packages asked for ^2.3.0 — the release the public/vendor
mirror landed in. But the mirror is two thirds of a mechanism without
PublishedAssets::flush(), and that shipped in 2.4.0.

Under Octane the memo that is per-request everywhere else lives as long
as the worker, so a worker surviving a deploy keeps answering with the
previous release's ?id=<mtime>. data-navigate-track, which exists to
notice exactly that, never fires, and a visitor navigating with
wire:navigate runs last release's controllers against this release's
markup until the worker recycles.

wire-core has called flush() from its RequestTerminated listener since
the mirror shipped, behind a method_exists() guard, because the
constraint allowed a release without it — so the flush was a silent
no-op on 2.3, the one deployment shape it exists for. Raising the floor
makes the call reachable on every installation the constraint permits
and the guard is gone, with a test that declares the event class Octane
would have brought and dispatches it, so the listener is exercised
rather than merely registered.

The providers were re-read against the 2.4 API while in there: the audit
subscriber moves from a hand-written Event::subscribe() in the booted
hook to hasSubscriber(AuditEventSubscriber::class), which the toolkit
subscribes in the same boot pass. The Blade component namespaces stay
manual — declaring them through hasComponentNamespaces() also registers
a publish tag that copies the packages' component classes into
app/View/Components/, where their namespace does not resolve.

It pulls the toolkit's own floor through too: 2.4.0 requires
illuminate/support ^12.61.1|^13.12.0, so the effective Laravel minimum
is 12.61 — tighter than the ^12.0|^13.0 the Wire packages declare, and
the version an install actually resolves against.
searchAs() says what a column holds — a code, a number, a date — so the
parser can compare it as that rather than as text. But the comparison it
allows has to be typeable: `10..20` is only a range if the table's
search reads ranges, and otherwise it is looked for as literal text.

Declaring the type without ->search(fn (SearchConfig $s) => $s->ranges())
therefore produced a table that came back empty and never said why. Both
halves are required and only one of them was checked.

SearchTypeGuard asserts the pair when the table renders, before anything
is typed, naming the call that is missing. A code column is asked for
tokenize() as well, because the series of a structured code arrives as
its own word — `8866 01..08` is the word `8866` and the range `01..08`,
so the column is only whole once the term is split too.

It stays quiet where there is nothing to assert: a text declaration, a
column that is not searchable, a table with no search box.
A context menu is open only by way of inline style, and the server
renders that panel with `display: none` on every request. So any morph
re-applies "closed" over it, and a morph that re-creates the teleported
panel throws the node away entirely. Under poll() — a render every tick
— a menu the user had open simply vanished, or worse, stayed on screen
with the focus gone and the arrow keys dead against it.

The panel node cannot carry the fact that it is open, so the module
keeps it: which record's menu, and where it was put. A `morphed` hook
restores both afterwards.

Deliberately a restore rather than a skip. A menu can stay open
indefinitely, and refusing the morph for as long as it does would freeze
the whole table behind it. The one case that does not restore is the row
having left the page — filtered out, or paged away by a tick — because
acting on a record no longer on screen is the surprise this exists to
prevent.

Four fixtures, because polling has more than one shape and they stomp
different things: poll() renders every tick and the morph is the hazard;
live() skips the render but still carries a full snapshot back, so the
client state Livewire syncs from it (a half-typed search box) is stomped
with nothing on screen to explain it; live(broadcast:) puts an Alpine
root on the poll wrapper, so pausing removes an x-data element wrapped
around the whole table; and poll() + queryString() is the everyday
shape, where the search term is read back from outside the component.
The toolbar carries the same crowding problem the mobile cards had one
level up: the search field, the filter trigger and the view menu already
sit in that row, and two labelled buttons ("New invoice", "Import CSV")
push it into a wrap at phone width.

collapseHeaderActionsOnMobile() folds them into a single trigger. Unlike
collapseActionsOnMobile() it needs no stackedOnMobile() — the toolbar is
the same toolbar at every width, so the collapse is purely a width
switch on the table's mobileBreakpoint(). Desktop is untouched. It folds
from two executable actions up, sooner than a card's row actions,
because the toolbar shares its row with the search field.

Getting there meant a header action has to be renderable as a menu row,
and only row actions were. Rather than teach the group a second kind of
item, the menu surface becomes a contract: RendersAsMenuItem, which
Action and HeaderAction both implement, so a folded header action goes
through the same canonical dropdown-item partial and cannot look like a
second kind of menu row. The view stays host-agnostic — the Livewire
expression arrives through ResolvesActionClick, and wire-table maps it
to its record-less header methods.

ActionGroup follows: the record is optional throughout, because a group
built on a record-less surface is not "nothing to show". Table's row
collectors now filter for Action explicitly, since a group can hold
members belonging to another surface.

EmptyStateActionClickResolver had already hand-written that exact
mapping; it collapses to a subclass of HeaderActionClickResolver so the
mapping has one owner, keeping its name because the empty state is where
a caller looks for it.

Both halves sit in the document at every width and CSS picks one, so the
folded copy renders without each action's keyboardShortcut(): a rendered
shortcut is a window listener, and two of them would run the action
twice on one keypress. Same reason the mobile row actions clone.
Whitespace only: Pint closes `?>` without the leading space. No rendered
output changes.
The toolbar's header-action fold, the context menu surviving a poll
tick, and the searchAs() guard all landed with tests, docs and browser
drivers but no changelog line. The guard goes under Changed rather than
Added: an application carrying that mismatch today has a search that
already finds nothing, and will now be told so on the next render.
architecture/assets.md and ADR 0024 both said the toolkit "has no
renderer and no route to fall back to", and used that as the reason
Foundation/Assets exists at all. Half of it stopped being true in 2.4 —
hasAssets(entries:) plus @packageAssets / @packageStyles / @packageScripts
/ @packageAssetUrl, with Asset::make()->classic() and ->attributes() —
and 768c299 raised the constraint to ^2.4.0, so the reason had gone
stale in the same release we started requiring.

What still justifies a renderer here is the fallback, not absence:
PackageAssets::url() returns null and stops, while ADR 0024 chose static
files with a route behind them for the app whose public/ is not
writable. The rest of Js's vocabulary now has a 2.4 equivalent, so the
docs carry the mapping — including the trap that would break a naive
port. These bundles are --format=iife, and the toolkit renders .js as
type="module" unless told classic(); a module is deferred and its
top-level declarations never reach window, which is exactly how the
registration idiom works, so a port would register nothing and fail with
no error at the point of the mistake.

hasViteAssets() is recorded as the one piece with no counterpart here
and a real consumer benefit — an app on Tailwind currently points
@source at this repo's Blade markup by hand. Not adopted.

Two other things the doc had wrong: the copy bundle moved to core in
2137b46 and was still listed under table, and the build-script comments
named the wrong outputs for both packages. assets.md was also
unreachable — nothing linked to it — so CLAUDE.md now routes JS asset
work there first.
The table I added claimed "full" overlap for the directives and for the
tag vocabulary, and concluded the route fallback was the only thing
keeping Foundation/Assets alive. Checked against the installed 2.4.1
source and package-toolkit.nyoncode.cz/assets, two of those are wrong:

- PackageAssets::tags(string $package, …) takes a REQUIRED package name.
  There is no aggregate form, so @wireStackScripts with no argument — one
  line in a consuming layout, whatever is installed — has no counterpart.
  Four calls instead, edited on every install. package:discover does not
  help: it discovers providers, and the template still names packages by
  hand. Worth noting the toolkit's own reasoning rejects a *generated*
  @blogStyles, not an aggregate, so a no-argument @packageAssets would be
  consistent with it.
- hasAssets() throws FileNotFoundException for an entry that is not a
  file in the asset directory, so a remote/CDN URL cannot be declared.
  Js uses http(s):// and // verbatim.

The fallback is confirmed: render() does `if ($tag === null) continue;`
and the docs say the fallback is the consumer's to implement.

loadedOnRequest() and the stale warning stay as differences rather than
blockers — the first is expressible per call site, the second is ours to
keep either way.
…ated it

wire-core carried an AssetManager, a Js value object, a Contracts\Asset
interface and an AssetRegistrationException for one reason: toolkit 2.3 had a
mirror and no renderer, so nothing else could turn a declared bundle into a
<script>. 2.4 added a renderer and left three gaps; 2.4.2 closes all three — the
no-argument @packageAssets, hasAssetFallback(), and remote URLs, which we
withdrew rather than asked for, since nothing here ships from a CDN.

So the four packages declare their bundles to the toolkit instead, and about 400
lines of parallel registry come out. What stays is Foundation\Assets\Bundle: the
three things true of every bundle in this repo, said once instead of in four
providers. classic(), because they are all esbuild IIFEs and a module's
top-level declarations never reach window — the Alpine registrar inside would
register nothing, and every x-data would fail with no error at the point of the
mistake. No defer, which classic() adds by default and which no browser check
had ever covered; a structural change should not carry a timing change in with
it. And data-navigate-once, for parity. Bundle::servedByRoute() is the other
half, pointing hasAssetFallback() at the {package}.asset route each package
already serves for the app whose public/ cannot be written.

Nothing changes in a consuming app. @wireStackScripts survives as an alias for
@packageAssets, and the emitted tags are identical apart from
data-navigate-track gaining its ="reload" value, which Livewire reads with
hasAttribute either way.

Two capabilities are given up deliberately. The stale-publish console.warn
cannot be rebuilt on this side: it needs each entry's absolute path, which the
toolkit does not expose, leaving only a second registry (the thing this removes)
or core learning which packages exist downstream (forbidden outright). It
belongs next to PublishedAssets::isStale(), which already knows the answer and
is not asked. And loadedOnRequest(), whose one user was wire-core-chart.js —
671 bytes of registrar around the app's own Chart.js, not the heavy body it was
filed as, and delivering a registrar late is the one thing ADR 0024 forbids. It
now ships with the rest, which is why the table's stack test counts seven
scripts rather than six. TipTap, the genuinely heavy case, was never an entry
and still is not.

Verified in a browser, not only in Pest: 65 CDP drivers, every reported check
count identical to the run taken before any of this moved.

ADR 0024's reasoning is unchanged and carries an amendment recording the
handover; architecture/assets.md is rewritten around what is now true.
The create/edit option modals were rendered from literals in the partial that
hosts them: the heading came from a narrow setter, `width: 'md'` was hardcoded,
the cancel label was read straight out of the translation file, and everything
else was unreachable. An option form holding more than a name field was squeezed
into a dialog narrower than the form inside it.

Both modals now carry the canonical Modals\Modal config object — the same one the
action modals use — and the partial projects it onto the Html\Modal render object
the way actions/modal-host.blade.php does. Heading, description, icon, width,
close behaviour, max height, sticky chrome, full-screen-on-mobile and both button
labels all work.

createOptionModalHeading() and the new createOptionModalWidth() stay as shorthands
writing into that same object rather than a parallel bag: two owners for one modal
is exactly why the cancel label was untouchable while the heading was settable.

The modal's id and its wire:model/close action are deliberately not configurable —
both option modals can be mounted at once and Livewire morphs them by that key.

An unconfigured option modal now honours wire-core.modals.default_width, where it
used to be md regardless.
A Wizard inside createOptionForm() rendered fine but never gated: "Next" calls
validateWizardStep(), the host answers by walking fieldActionForms(), and that
enumeration knew only the host's own forms and an embedded action-modal form. No
wizard matched, the method returned true, and every step advanced regardless of
what the user had typed. Nothing was saved invalid — createSelectOption()
validates the whole form on submit — but the errors surfaced all at once at the
end, possibly on a step no longer on screen.

The same omission broke two more things through the same path, since
resolveFieldForAction() shares that enumeration: a Select nested in an option
form could not reach the remote-search endpoint, and field actions inside an
option form resolved to nothing and silently did not run.

The mounted create/edit option forms now join the enumeration. Resolving one has
to look its Select up somewhere, and looking it up in the full set would re-enter
the method being built, so field resolution gains a base flavour
(resolveBaseFieldForAction(), host forms only) that the option-form lookup uses.

Opening an option modal for a field that itself lives inside an option form is
refused: there is one mounted path and one data bag per kind, so honouring it
would discard the form being filled in. That combination was previously
unreachable because the field could not be found, so it now has to be turned away
explicitly.
A wizard inside a create-option modal rendered its own Previous/Next row at the
bottom of the panel while the modal's footer sat right underneath it with Cancel
and Create — two navigations stacked, and a Create button live on step 1 of 3.

Wizard::navigation(false) drops the built-in row. The wizard keeps owning the step
state and now publishes it, so an outer surface can render the controls: it emits
wire-wizard-state ({wizard, step, total, validating}) and accepts
wire-wizard-navigate ({wizard, direction}), both scoped by the wizard's name.
Window events rather than a bubbling $dispatch, because a driving footer is a
sibling subtree of the wizard, not an ancestor. 'next' runs the wizard's own
next(), so an external button gates on the same per-step server validation as the
built-in one instead of reimplementing it.

The Select option modals consume this with no extra call: a navigation(false)
wizard in createOptionForm() moves Back/Next beside Cancel and reveals the submit
button only on the last step. The footer seeds its step total server-side from the
wizard's visible step count, so the controls are right on first paint rather than
after the first broadcast, and the wizard stays silent until its own count has
synced — publishing the pre-sync total of 0 told the footer there were no steps
and collapsed its controls for a frame.

That last one came from the browser driver, not from a test: verify-option-wizard
(12/12) against a new /previews/forms-option-wizard clicks the footer and watches
the wizard panel move rather than asserting on markup.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@ONyklicek
ONyklicek merged commit 05c02ac into 1.x Aug 10, 2026
18 of 19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant