Known traps
This page lists failures that developers building on this package have encountered in practice, where the page renders, no exception is raised and no log line is written. It is written for a developer who has something that is not working and no error message to search for. Each failure surfaces later and indirectly: as a control in the panel that appears to do nothing, as markup that is wrong in a way a browser does not report, or as a feature that reads as switched off.
Every entry has the same three parts:
- Symptom: what is observed. The symptom is listed first because it is the information a developer has when looking for the entry.
- Cause: what the code does to produce the symptom.
- Fix: what to change, and how to confirm the change worked.
Width classes emitted with no CSS rule
Symptom
On the About page, which uses the wide layout, prose blocks stored as narrow were clamped to 721px in a 1280px viewport, while the same section on the home page ran full width.
Cause
rich_text, image and content_section each had their own width field before the shared width control existed, and emitted .cms-block--narrow and .cms-block--full. Neither class had a rule in the base theme or in any skin, so the classes had no effect for months. Adding rules for those class names later applied the stored values with no change to the blocks' code.
Fix
Before introducing a class name, check whether it already has a rule. Before writing a rule for a class name, check whether any block already emits it.
A published layout overrides the package's layout everywhere
Symptom
A change to one of the package's layouts appears to have no effect.
Cause
resources/views/vendor/cms/layouts/default.blade.php is registered ahead of the package's own layout directory, so that publishing a layout overrides it. The override applies to everything: every content type that does not name a custom layout, every preview, and every test that runs afterwards in the same process. A layout published experimentally and left in place serves every later request.
The same applies to resources/views/vendor/cms/blocks. Publishing every block view excludes all of them from changes made to those views in later package releases.
Fix
If a layout change has no effect, look in resources/views/vendor/cms/layouts first and delete any published layout you do not intend to keep. Publish only the block views you intend to change.
A slot the layout never echoes still renders
Symptom
Every link in every table of contents on a docs, sidebar or home page is dead: the contents list links to #content while the rendered heading carries id="content-2". Nothing appears duplicated on the page.
Cause
The content view passes the default region as the slot, so a layout with no region tags receives its blocks through {{ $slot }}. Blade evaluates slot contents whether or not the component echoes them. A layout that ignored the slot and called <x-cms::region name="main" /> itself therefore rendered every block in main twice: once into the discarded slot and once on the page. Only the second pass reached the page, but the discarded pass had already used the anchor content, so the visible heading received content-2. Every query those blocks make also ran twice.
Fix
The package fixes this in the content view, which now fills the slot only when the layout does not declare a main region of its own. The check reads what the template declared, not what LayoutRegions reports, because LayoutRegions reports main for every layout, including layouts that never contain the tag.
Specificity ties settled by source order
Symptom
A CSS rule that is present in cms.css has no effect on the page, and the element carries the class the rule names.
Cause
The rule is tied or beaten on specificity rather than ignored. Three cases were measured in cms.css:
.cms-aside h2is (0,1,1) and.cms-block--rich-text h2is also (0,1,1). Source order settled the tie, which left a 3xl display heading in a 17rem sidebar column. Qualifying the selector with the block class raises it to (0,2,1).:where()contributes no specificity, so:where(.cms-block--hero)loses to.cms-block. The hero was left inset, with the page visible on either side of it.:is()contributes its argument's specificity. Use:is()when the selector must win and:where()when it must not.- An existing
.cms-blockrule hard-codedmax-w-6xland sat below the new width rules, so every width preset resolved to 1152px even though the correct custom property was set on the element. The bleed rule lost to the same rule and had to be moved below it.
All three were found by measuring in a browser. A fourth case, involving a skin rule, is described under A skin rule winning a specificity tie.
Fix
- Calculate the specificity of the rule and of every rule that matches the same element before rewriting a rule that appears to be ignored.
- Raise the specificity or move the rule, depending on which rule should win.
- Check the result in a browser at a real viewport width.
A block view that drops $cmsAttrs
Symptom
The width preset, custom classes, anchor and ARIA role that an editor set in the panel have no effect on the page. The block's own content renders, validates, saves and publishes correctly.
Cause
The block's view does not merge $cmsAttrs into its root element. Those five controls appear in the panel and are stored correctly in the version. Only the final rendering step discards them. No error is raised, so the failure is usually reported later as a panel control that does not work.
The generator was fixed on 2026-08-07: cms:make-block emits the $cmsAttrs merge and the cms-block--{key} class, and two tests in GeneratorCommandsTest cover it. The previous stub wrote class="cms-block cms-block-{key}" (a single dash, which no stylesheet targets) and no $cmsAttrs. The same failure remains possible in any hand-written or forked view.
Fix
Keep the $cmsAttrs merge when forking a block view, and include it when writing one. A one-line test asserting on the width class detects its absence.
A temporary upload's real path is not a path on S3
Symptom
An upload fails with File does not exist, naming a path that was never local, while the file is in the bucket where it should be. The failure does not reproduce on a development machine.
Cause
Any host running more than one application container, Laravel Cloud included, must set livewire.temporary_file_upload.disk to S3, because the browser uploads the file to one container and submits the form to another.
TemporaryUploadedFile::getRealPath() returns Storage::disk($disk)->path($path). On a local disk that value is an absolute filename. On S3 it is the bare object key. Code that passes that value to an API expecting a local file produces the error above.
A development machine has the temporary upload disk set to local, where getRealPath() returns a correct filename, so the code works until it is deployed.
Fix
Wrap the path-taking call in TemporaryUpload::withLocalCopy(). It passes your callback a local path and deletes the temporary copy afterwards, including when the callback throws. When the temporary disk is already local it copies nothing and passes the existing path, so it can be used on every upload:
use Mcg\FilamentCms\Support\TemporaryUpload;
TemporaryUpload::withLocalCopy($upload, fn (string $path) => $item
->addMedia($path)
->usingFileName($upload->getClientOriginalName())
->toMediaCollection(MediaItem::COLLECTION));
Media Library's addMediaFromString($file->get()) and addMediaFromStream($file->readStream()) read through the disk and need no change. Only APIs that take a file path need the wrapper.
Stale CSS from a leftover public/hot file
Symptom
Styles are stale after a rebuild, and rebuilding again changes nothing.
Cause
A public/hot file was left behind by a Vite dev server that is no longer running. While the file exists, Laravel points asset URLs at that dev server, and the browser serves a stylesheet cached from before the change.
Fix
Delete public/hot. Laravel then serves the built asset.
backdrop-filter creates a containing block
Symptom
The mobile navigation panel and its scrim are sized to the 69px header instead of the viewport.
Cause
backdrop-filter on .cms-header made the header the containing block for its fixed-position descendants.
Fix
The property is now on .cms-header::before, which paints the same effect without creating a containing block. A test asserts that no rule in cms.css or signal.css places backdrop-filter anywhere else.
An eased visibility transition computes hidden on the first frame
Symptom
focus() on a newly opened panel has no effect, and no error is raised.
Cause
The panel's visibility transition is eased, so its visibility computes as hidden on the first frame after opening.
Fix
Use a stepped transition: visibility 0s linear 0s when opening and visibility 0s linear 0.32s when closing. Do not use a forced reflow; a reflow addresses the symptom rather than the transition.
A skin rule winning a specificity tie
Symptom
On one skin, the hero's h1 renders at body-copy size. On the other skins it is correct. No conflict is reported.
Cause
The skin set its heading sizes with [data-cms-skin='press'] h1.cms-block__heading, which is (0,2,1). The hero's own heading rule is (0,2,0). Skins are imported after cms.css, so the skin rule won everywhere, including inside the hero. A skin is loaded last, so it wins every specificity tie it enters.
Fix
Exclude the elements the skin rule should not reach with a selector that adds no specificity: :not(:where(.cms-block--hero, .cms-block--hero-carousel) *). :where() counts as zero specificity, so the :not() adds none.
The same padding declared on a wrapper and on what it wraps
Symptom
A multi-slide hero has 429px of content inside 704px of height. The same block measured 1133px locally against 645px on production.
Cause
.cms-block--hero and .cms-carousel__slide each declared the same padding-block. A single-slide hero renders as static markup and applies the padding once. A multi-slide hero renders the carousel inside the block and applies it twice. The local and production measurements differed because the two heroes were not the same: one held one slide and the other held several. The doubled padding looked like a deliberate amount of space until it was compared with the same hero holding one slide.
Fix
Where a wrapper and its contents can both be styled, assign the spacing to one of them, and check the case where both are present.
A default width preset mapped onto its neighbor by a layout
Symptom
On the reading layout, the normal and narrow width presets render at the same width. Nothing reports that two of the four presets are identical.
Cause
The width control offers narrow, normal, wide and bleed. normal is the default and emits no class, so it takes whatever width .cms-block has. .cms-main--reading clamps .cms-block to the narrow measure, so normal became identical to narrow.
Fix
A preset that emits no class inherits whatever the layout does to the base class. If a default must be a distinct value, give the base class that value explicitly and let each layout that needs a different value override it.
A style change that leaves half a rule behind
Symptom
In the navigation block, the current sub-page link renders 0.75rem left of its siblings.
Cause
The current-page link carried an accent border and a negative left margin equal to the sub-level indent, so the border sat on the rule its siblings hang from. A later commit removed the border and left the negative margin.
Fix
When a declaration exists only to make room for another, remove both together. A leftover declaration of this kind produces a shift that looks like a layout defect.
The language switcher shows letters instead of a flag
Symptom
On Windows, Chrome and Edge draw the flag in .cms-languages__flag as the two letters it is built from, for example CA instead of the Canadian flag. Firefox on the same machine draws the flag correctly.
Cause
The markup is correct. Locales::flag() returns a pair of regional indicator symbols (fr-CA becomes U+1F1E8 U+1F1E6), which is the sequence every platform draws a flag from. Segoe UI Emoji, the Windows emoji font, has no flag glyphs, so Chrome and Edge draw each indicator on its own as a letter. Firefox bundles an emoji font that includes the flag glyphs. A flag that renders in Firefox and not in Chrome on the same machine therefore indicates a missing font rather than a content error.
The package names a font that has the glyphs and bundles none. The theme's typefaces are loaded rather than shipped for the same reason: no font license is distributed with the package.
Fix
- In a terminal at the site's root, install the font file with
npm install country-flag-emoji-polyfill. - In your own stylesheet, add the
@font-facerule:
@font-face {
font-family: 'Twemoji Country Flags';
src: url('../../node_modules/country-flag-emoji-polyfill/dist/TwemojiCountryFlags.woff2') format('woff2');
unicode-range: U+1F1E6-1F1FF, U+1F3F4, U+E0062-E0063, U+E0065, U+E0067,
U+E006C, U+E006E, U+E0073-E0074, U+E0077, U+E007F;
font-display: swap;
}
- On your site's credits or license page, attribute the artwork. It is Twemoji, licensed under CC-BY-4.0, which requires attribution.
Only the font file from that package is used. The package's JavaScript exists to inject the same rule at runtime, and the front end ships no JavaScript unless a block requests it. The unicode-range descriptor keeps the 76KB font from being downloaded on pages that show no flag, and confines the family to flag code points so that naming it changes no other text.
A locale with no region subtag gets no flag. Locales::flag() returns null when the locale has fewer than two parts or its last part is not two letters. en and fr render as a bare code; fr-ca and es-mx get a flag.
Two robots meta tags on one page
Symptom
The page carries two <meta name="robots"> tags, one saying index and one saying noindex. Which one a crawler honors is not defined.
Cause
<x-cms::head> always emits one robots tag, through the seo component, with a default of index,follow. A view that needed noindex added its own tag underneath instead of passing the directive to the component, so the page was sent with both. The affected pages were the 404 page and every page after the first of a tag archive, where noindex exists to stop paginated slices from competing with page one in search results.
The test that should have caught it asserted toContain('noindex'), which is also true of a page that carries index,follow beside it.
Fix
Pass the directive to the component through its robots key. A null value falls through to the default: <x-cms::head :seo="['robots' => 'noindex, follow']" />
In a test, count the robots tags rather than searching for the substring.
The 404 page that could never be displayed
Symptom
The package's themed 404 view, which shipped for months, is never shown on any installation.
Cause
Every not-found path throws NotFoundHttpException, which Laravel resolves against the application's errors.404 view. The package's view is cms::errors.404, a namespace that resolution never looks in, and there was no publish tag for it.
The only code rendering the view was a test calling view() directly, so the test suite passed while no visitor could reach the page. This is the same pattern as the $cmsAttrs entry above: the test exercised the component, not the wiring, so it proved only that the component compiles.
Fix
The package fixes this with RendersCmsNotFound, which serves the view on the CMS's own routes. When writing middleware like it: Illuminate\Routing\Pipeline overrides handleException() to render a throwable through the exception handler inside the pipeline, so $next() returns a finished 404 response and a catch around it never runs. Read the response status instead.
A tree screen that is permanently empty
Symptom
A content type has a Tree screen in the panel. The screen loads, draws its header and its empty state, and shows no rows, however many rows the list screen shows for the same type. Nothing is logged and no filter is applied.
Cause
The type's rows are parented to content of a different type. ContentTree::forType() does two things in sequence:
$rows = Content::query()->where('type', $type)->get(); // one type only
return $this->nest($rows, null); // roots = parent_id null
The first line restricts the result to one type. The second treats a row as a root only when its parent_id is null. A row whose parent is a different type therefore satisfies neither condition: the parent is not in $rows to nest under, and the row is not a root because its parent_id is set. Every such row is dropped, and a type where every row is parented across types produces a tree with zero roots.
The case that produced this entry was a post type with a routePattern() of /blog/{slug} and isTree() returning true, whose five posts were each parented to /blog, a page. The Blog Posts tree screen drew nothing, while the list screen drew all five.
A tree screen cannot show a parent of another type. The screen's drag handler moves a row to a new parent within the type the screen is scoped to, and the move writes a path through PathResolver. A branch containing rows of another type would offer drag targets for which no move behavior is defined.
To confirm this cause, count the rows the tree screen can draw as roots:
// Rows of this type that the tree screen can draw as roots:
Content::query()->where('type', 'post')->whereNull('parent_id')->count();
A result of zero means an empty tree, whatever the total row count is.
Fix
Return false from isTree() on a type whose rows are parented across types, or whose rows have no parent at all. This removes the Tree screen.
Before doing so, check the second effect of the same method. isTree() governs two unrelated things:
- Whether the Tree screen exists.
TreeContent::canAccess()returns$type->isTree() && β¦. - Whether the Parent select appears on the edit form.
ContentFormadds it insideif ($type->isTree()).
Returning false removes both. The parent_id values already in the column are left in place, but there is no longer any control that sets one, so new content of that type cannot be given a parent.
Check what reads parent_id for the type before returning false:
Breadcrumbs::parentOf()walksparent_idand does not filter by type, so abreadcrumbsblock on that content shows no trail for content created after the change. This is the usual reason to leaveisTree()returning true despite the empty screen.- The
page_listblock takes aparent_idand lists that page's children. A type that gets its index page frompage_listneeds the parent set. A type whose index block queries by content type does not. PathResolverwalks the parent chain only for a type with noroutePattern(). A route pattern fixes the path regardless of the tree, so the URL does not change.
If the type needs a parent but has no use for the screen, there are two options:
- Leave
isTree()returning true and accept the empty Tree screen. - Separate the two concerns in your own content type subclass. The package does not currently separate them.
A utility naming a token you have not defined compiles to nothing
Symptom
One element renders unstyled, for example a button the color of the page or a heading in the body typeface, while every element around it is styled correctly. There is no warning, no error, and nothing in the build output.
Cause
Tailwind resolves bg-brand-600 against a --color-brand-600 token. When the token does not exist, the utility is not emitted at all, so the class is present in the markup and selects no rule. This usually happens in a bespoke skin or a block override, because both are written against token names before the tokens themselves are defined.
Fix
Define the tokens in the same stylesheet that imports cms.css:
@theme {
--color-brand-500: #dc2626;
--color-brand-600: #b91c1c;
--font-display: 'Oswald', var(--font-sans);
}
When an element looks unstyled, check that the token exists before checking anything else. The full token vocabulary is on the design system page.
A Tailwind utility in a panel view compiles to nothing
Symptom
An element in the admin panel renders unstyled, for example an image that ignores its container and extends past it, or a row whose items are the wrong size, while the same class works in a front-end view.
Cause
Filament v5 ships a semantic stylesheet with classes such as fi-section, fi-btn and fi-badge, and no generic utilities. w-full, flex, gap-3 and rounded-lg in a panel view therefore select nothing in a customer's installation. They work in the package's own monorepo only because its resources/css/app.css carries @source lines pointing at the package's views, which a customer's installation does not have.
No error is produced in either environment. The class is in the markup, the element is on screen, and the browser's inspector reports the computed value rather than the value the class was meant to set. When the media picker's thumbnail carried w-full object-contain, the element reported max-width: none and object-fit: cover, which came from Filament's own rule rather than either class, and a 400px image extended out of a 256px card.
The previous entry describes the reverse case, where the utility exists and the token does not.
Fix
- In the browser's inspector, check the element's computed style rather than its class list.
- In
resources/css/panel.css, write the rule under a class name that starts withfi-cms-. The service provider inlines that stylesheet into the panel's<head>.
Filament's published assets older than the installed Filament
Symptom
After a composer update, the panel's browser console fills with ReferenceError messages such as filamentGlobalSearch is not defined, isTabSelected is not defined and dismiss is not defined. Tabs, repeaters, date pickers and notifications stop responding. One request in the network log returns 404, for a file such as /js/filament/forms/components/repeater.js?v=5.10.2.0.
Cause
Filament serves its JavaScript and CSS from copies under public/js/filament and public/css/filament, published by php artisan filament:assets. Composer does not refresh those copies. When the installed Filament is newer than the copies, the panel requests files the old set does not contain, and every Alpine component defined in those files is missing.
Fix
- In a terminal at the application root, run
php artisan filament:assets. - In the browser, reload the panel. The 404 and the
ReferenceErrormessages no longer appear.
To prevent a recurrence, check that composer.json contains @php artisan filament:upgrade under scripts.post-autoload-dump. That entry re-publishes the assets after every Composer update. cms:install adds it since 0.78.4; an application installed earlier may not have it.
Nested config keys missing after an upgrade
Symptom
A feature added in a new release reads as switched off on an installation that upgraded, and switched on everywhere else. Nothing in the release notes says it is opt-in, and the application's config file has no key to turn on.
Cause
Laravel merges a package's config into the application's with mergeConfigFrom, which merges only the top level. A published config/cms.php already has a blocks key, so the package's blocks array is discarded whole, including any key added to it after the file was published. The same is true of theme, forms and every other top-level group except mcp.
mcp is the exception since 0.78.4. FilamentCmsServiceProvider::fillMissingConfigKeys() adds any named key under mcp that the published file lacks, recursing into named groups such as mcp.web and mcp.blocks. It never adds to or replaces a list, so a published 'gated' => [] stays empty.
An installation that has never published config/cms.php is unaffected. The only installations that show the symptom are ones that published the file and then upgraded, which is why it is hard to reproduce.
The shallow merge has affected this package three times, each time on a key inside an existing group:
blocks.livewireenables single-file Livewire blocks. A host that published its config before the key existed found that the whole mechanism did nothing. The key is now read with a default at the call site,config('cms.blocks.livewire', ['blocks' => null]), so an upgrade turns it on. A host that wants it off writes'livewire' => [].mcp.settings.deniedlists the site-setting groups the MCP server refuses to read. Two groups added to the list hold credentials:webhookscarries each endpoint's HMAC signing secret, andnotificationscarries a Slack incoming-webhook URL. An installation with its own array never received either. Those two groups are now unioned in at the call site rather than defaulted, so no configuration can remove them.mcp.namewas added in 0.78.3. An installation that setCMS_MCP_NAMEsaw no change, because its publishedmcpgroup had no key reading the variable. This case led tofillMissingConfigKeys(). The same fill also addsmcp.blocks.gatedto a config published before that key existed, so on such an installation thehtmlblock now requiresconfirm_raw_htmlover MCP, which is the shipped default.
Fix
- When adding a nested key to this package, read it with a default or union it in at the point of use. Do not rely on the application having the shipped config file.
- When upgrading an installation, diff the published
config/cms.phpagainst the package's copy after each release rather than assuming the merge added new keys. Command and config reference lists every key the current version ships.
Text contrast below 4.5:1 in a skin
Symptom
There is no visible symptom. The site looks correct, and the token that fails is the one designed to look muted.
Cause
The design system documents a 4.5:1 contrast floor for every text color against the surface it sits on, in light and dark mode, in every skin. The floor was documented for months without being measured. When it was measured, the base theme and four of the five skins that existed at the time were below it on --color-cms-faint, and had been since each was written:
| Skin | faint, light | faint, dark |
|---|---|---|
| Base | 3.36 | 3.96 |
| Press | 3.44 | 4.05 |
| Signal | 3.67 | 3.86 |
| Marquee | 4.10 | 4.00 |
| SaaS | 2.51 | 4.23 |
| Arcade | passed | passed |
The base theme's --color-cms-code-comment measured 3.80 in light mode. Those tokens color captions, navigation headings, the language switcher, footer notes, small block headings and code comments. All of them are text, so 4.5:1 is the correct floor and none of them qualifies for the 3:1 exception for non-text elements.
Arcade passed because its palette had been calculated for contrast rather than chosen by eye. A muted token is the one most likely to fail unnoticed: faint is designed to look faint, so a visual check of the page reports it as working.
The fix in v0.29.2 itself pushed two skins below the floor. Both are dark by default, and darkening a light-mode value for their own surface changed a value that had previously passed.
Fix
The v0.29.2 fix moved lightness only, so each palette keeps its hue and chroma. For a new or changed skin:
- Measure every text token's contrast ratio as a number before shipping the skin, in light and dark mode.
- Measure again after any change to the palette.
- Model the real cascade when measuring. A skin's root rule is (0,1,0) and the package's dark-mode block is (0,2,0), so the package's dark values override the skin's root values, and only the skin's own dark-mode rule overrides the package. Measuring the tokens as a flat map tests a stylesheet that does not exist.
A partial write blanks the page's title
Symptom
An edit that changed only blocks succeeds and the blocks are correct, but the page's title, navigation label and three exclusion flags are gone. Nothing is logged, and the site looks correct until that draft is published.
Cause
A version stores two kinds of data: the block list, and the page's own fields (title, subtitle, nav_label, display_date and the exclude_from_* flags). VersionManager::save() writes exactly the payload it is given, so a caller that supplies blocks and nothing else writes a version whose attributes are [].
This defect was present in the package until it was found by diffing a page's first inline save against the version before it: every block reported unchanged while title went from "About Us" to null. Three callers had each worked around it at their own call site, which hid it. The MCP block tools spread contentAttributes() into their payload by hand, and the shared-region action passes title explicitly.
Fix
Write through ContentWriter::update(), which carries the existing fields forward. It reads the current draft, or the published version when there is no draft, and merges the payload over it, so a field the payload does not mention keeps its value. It applies the same rule to the content row's own columns and to blocks in a region that the submitted form did not include:
// Keeps the title, the flags and the SEO. Changes only the blocks.
app(ContentWriter::class)->update($content, [
'blocks' => $blocks,
'change_note' => 'Edited from my own tool',
]);
// Clearing is still possible, by naming the field rather than omitting it.
app(ContentWriter::class)->update($content, ['nav_label' => null]);
VersionManager::save() is the lower layer and has no such fallback. Publishing, restoring and the importer are built on it, and each of those supplies a complete payload. If you call VersionManager::save() directly, supply the attributes yourself with $version->contentAttributes().
The panel builder shows a container as Empty
Symptom
A page whose columns, tabs or accordion render correctly on the front end opens in the panel with that block collapsed to Columns β Empty. Saving the form, even a save that changed only the page title, writes the empty container back, and the children are missing from the new draft.
Cause
Three containers accept rows stored in a shape that predates them. A column stored before columns could hold blocks has heading, body and cta_label keys and no blocks key. BlockDefinition::cast() converts those fields into a one-block list, and it runs on read: BlockTree::walk() calls it. The front end, the MCP block paths and inline editing therefore see children that the stored array does not contain.
A reader that reads the stored array directly sees the legacy shape and no children. The panel's builder was one such reader. EditContent::fillForm() filled it from $version->blockList(), the builder rendered an empty repeater, and on save the form's empty state replaced the stored children.
// Wrong: hands the builder whatever shape the version happens to be stored in.
$blocks = $version?->blockList() ?? [];
// Right: the same children cast() gives every other reader.
$blocks = BlockTree::upgraded($version?->blockList() ?? []);
BlockTree::upgraded() maps cast() over a block list and is idempotent, so running it on an already-upgraded list returns the list unchanged.
BlockTree::upgraded() casts at every level of nesting. Its first version cast only the top level, which caused a second failure. A container's cast() creates children from the old row's fields and stops there: it converts a cta_label into a button_group, and does not apply what a button_group requires. alignment is a required field on button_group, no column ever stored one, and the created child reached the form without it. The builder shows no alignment field for a nested child, so there was nothing on screen to fill in, and the page refused to save with this error:
Block [button_group] at position 2 βΊ columns 1 βΊ blocks 2 is invalid.
The alignment field is required.
The error appeared on a block whose author had changed nothing, and on a three-column band it appeared for all three columns. The renderer substitutes a default value for the missing field, so the page looks correct on the front end until the next save.
The same defect affected BlockTree::replaceAt() and the inline editing mutations. They read the raw data to find a block's siblings, data_set() created the missing blocks key, and LegacyContainers::rows() skips any row that already has a blocks key. The column was therefore marked as upgraded while its created children were dropped.
A third instance produced the opposite symptom. BlockTree::moveBefore() read a block's siblings through cast() and wrote them back unconditionally, so on a legacy container a move that reordered nothing still returned a different array: the upgraded one. Callers that test $moved === $blocks to decide whether anything changed always decided that something had, and wrote a new version when an editor clicked a drag handle without moving anything.
Fix
The package now applies these rules, and a tool built on the package should follow them:
- Read a block's children through
BlockTree, not directly fromdata. Every path throughBlockTreereads siblings throughcast(). - Pass a block created by an upgrader through its own
cast()before anything validates it, at every level of nesting. This is the same ruleslotsOf()follows. - Treat writing the cast shape back as a change. Code that compares a block list before and after an operation must compare two lists in the same shape, or defer the upgrade to a write that was going to happen anyway.
moveBefore()now returns its input untouched when the order is unchanged.
Rebinding JavaScript on morph.updated instead of morphed
Symptom
Code that re-initializes a third-party library after a Livewire re-render works for elements that were already on the page and not for elements the render just added. The new elements start working after a second, unrelated re-render.
Cause
Livewire publishes both a morph.updated hook and a morphed hook, and they run at different times. morph.updated runs once per element, while the morph is still in progress, and its el is the element being updated. morphed runs once, after morph() has returned (trigger2("morphed", { el, component }) in livewire.js), and its el is the component's root element.
Code that re-attaches a library by querying the document from morph.updated queries a DOM that is still being assembled, and the elements this render adds do not exist yet:
// Wrong: runs before the elements this render is adding exist.
Livewire.hook('morph.updated', ({ el }) => { rebind() })
// Right: runs once, with the finished DOM.
Livewire.hook('morphed', ({ el }) => { rebind() })
The page tree had this defect. Sortable was rebound on morph.updated, so expanding a node queried for its lists before that node's list had been inserted, and the new list received no Sortable instance. The list became draggable only when a later morph, such as expanding a second node, found it already present. Measured after a single expand, morph.updated bound 1 of 6 lists and morphed bound 6 of 6. Fixed in v0.53.1.
The defect was present but had no visible effect while the tree rendered expanded, because every list existed when the initial binding ran. Commit 877025b made the tree open collapsed, so a list had to be revealed before it could be dragged, and from that commit the defect made dragging fail.
Fix
- Register the rebinding code on
morphedrather thanmorph.updated. - Reverse the containment test at the same time. In
morph.updated,elis a descendant, so the component checksthis.$el.contains(el). Inmorphed,elis the component root, which contains the Alpine element instead. A condition carried over unchanged never matches, and the rebinding code never runs. The page tree inresources/views/admin/content-tree.blade.phpchecks all three relationships:el === this.$el || el.contains(this.$el) || this.$el.contains(el).