[🐞] V2 Native `<dialog>` loses top-layer membership after `routeAction$` submit when wrapped in layout and uses a loader
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 22.1k
- Forks
- 1.4k
- Avg merge
- 1d 10h
- Merged PRs (30d)
- 52
Description
Which component is affected?
Qwik Runtime
Describe the bug
[🐞] V2 Native loses top-layer membership after routeAction$ submit when wrapped in layout and uses a loader
Summary
When a native <dialog> is opened with .showModal() and contains a
<Form action={routeAction$(...)}>, submitting the form silently demotes the
dialog from the top layer as long as any parent route has a layout.tsx and it uses a loader or context(not sure which one is the problem).
After the action resolves:
dialog.open === true(theopenattribute is still there)dialog.matches(':modal') === false(top-layer membership is gone)- No
closeevent fires - No JS in the app calls
.close() ::backdropno longer renders, the dialog falls into normal flow, and it
is no longer centered — it visually appears closed even though it isn't
Calling dialog.close(); dialog.showModal() re-promotes it correctly,
proving the element itself is healthy — only its top-layer membership is
lost.
If the layout is removed (i.e. the route renders without any wrapping
layout.tsx), the bug does not reproduce. The contents of the layout
do not matter — a layout that is just <Slot /> is enough to trigger it.
The bug is not specific to routeLoader$ or useContextProvider; those
were initially suspected but are not required.
Environment
@qwik.dev/core:2.0.0-beta.36-dev+3268fab@qwik.dev/router:2.0.0-beta.36-dev+3268fab- Reproduced in Chrome (latest stable) and StackBlitz preview
- Vite dev mode
Reproduction
StackBlitz:
Two files only:
src/routes/layout.tsx
import {
component$,
Slot,
createContextId,
useContextProvider,
} from '@qwik.dev/core';
import { routeLoader$ } from '@qwik.dev/router';
type FakeUser = { id: string | null; ts: number };
export const FakeUserContext = createContextId<FakeUser>('fake-user');
export const useFakeUserLoader = routeLoader$<FakeUser>(async () => {
// Return a fresh object identity each call to mimic an auth lookup.
return { id: null, ts: Date.now() };
});
export default component$(() => {
const userSig = useFakeUserLoader();
// Pass the resolved value (new object identity each revalidation) into context.
useContextProvider(FakeUserContext, userSig.value);
return <Slot />;
});
src/routes/index.tsx (or any nested route)
import {
component$,
useSignal,
useVisibleTask$,
$,
} from "@qwik.dev/core";
import { routeAction$, Form } from "@qwik.dev/router";
export const useEchoAction = routeAction$((data) => ({
ok: true,
echoed: data,
}));
export default component$(() => {
const dialogRef = useSignal<HTMLDialogElement>();
const status = useSignal("(not opened)");
const action = useEchoAction();
useVisibleTask$(({ cleanup }) => {
const id = setInterval(() => {
const d = dialogRef.value;
if (!d) return;
status.value = `open=${d.open} isModal=${d.matches(":modal")}`;
}, 200);
cleanup(() => clearInterval(id));
});
return (
<main>
<p>Status: <code>{status.value}</code></p>
<button type="button" onClick$={$(() => dialogRef.value?.showModal())}>
Open dialog
</button>
<button
type="button"
onClick$={$(() => {
const d = dialogRef.value;
if (!d) return;
d.close();
d.showModal();
})}
>
Re-promote (close + showModal)
</button>
<dialog ref={dialogRef}>
<Form action={action}>
<label>
Email <input type="email" name="email" defaultValue="a@b.co" />
</label>
<button
type="button"
onClick$={$(() => dialogRef.value?.close())}
>
Cancel
</button>
<button type="submit">Submit</button>
</Form>
{action.value?.ok && <p>OK: {JSON.stringify(action.value.echoed)}</p>}
</dialog>
</main>
);
});
Steps
- Visit the route.
- Click Open dialog — modal appears centered with backdrop. Status shows
open=true isModal=true. - Click Submit inside the dialog.
- Modal visually disappears. Status now shows
open=true isModal=false. - Click Re-promote — the modal reappears correctly, confirming the
element is fine and only top-layer membership was lost.
Expected behavior
After the routeAction$ resolves and Qwik finishes rerendering, the dialog
should remain in the top layer. dialog.matches(':modal') should still be
true and ::backdrop should still render. Other reactive UI updates
(reading action.value, action.status, etc.) should not affect top-layer
membership of an unrelated open <dialog>.
Actual behavior
The dialog is silently demoted from the top layer:
openattribute remainstrue:modalisfalse::backdropno longer renders- Dialog renders in normal flow, appearing "closed" to the user
- No
closeevent fires; no user/app code called.close()
Suspected cause
After the action response, the router revalidates loaders and Qwik patches
the route subtree. The presence of a parent layout.tsx (even one that
just returns <Slot />) seems to change how Qwik patches the DOM around
the <dialog> — enough that the browser drops it from the top layer.
Without a layout, the patch path is different and the dialog stays
promoted.
The HTML spec demotes a dialog from the top layer whenever the element is
removed from the document or otherwise disconnected. Most likely Qwik is
briefly detaching/reinserting an ancestor of the <dialog> during the
post-action patch when a layout is involved.
Workarounds tried
- Removing the parent
layout.tsx— bug disappears, but obviously not
viable for real apps. - Removing the loader / context provider from the layout — does not
fix the bug; an empty<Slot />-only layout still reproduces it. - Calling
dialog.close(); dialog.showModal()after the action resolves
— re-promotes correctly, but is a hack that flickers and breaks form
state.
Impact
Any Qwik v2 app that uses native <dialog> for modal UX with a
routeAction$-driven <Form> inside it is broken: every submit silently
"closes" (demotes) the modal even though the application never asked for
that. Apps using qwik-ui Modal or other dialog wrappers that rely on
.showModal() are affected the same way.
Reproduction
Steps to reproduce
No response
System Info
System:
OS: Linux 5.0 undefined
CPU: (8) x64 Intel(R) Core(TM) i9-9880H CPU @ 2.30GHz
Memory: 0 Bytes / 0 Bytes
Shell: 1.0 - /bin/jsh
Binaries:
Node: 22.22.0 - /usr/local/bin/node
Yarn: 1.22.19 - /usr/local/bin/yarn
npm: 10.8.2 - /usr/local/bin/npm
pnpm: 8.15.6 - /usr/local/bin/pnpm
npmPackages:
typescript: 5.9.3 => 5.9.3
vite: 7.3.1 => 7.3.1
Additional Information
No response
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by reproducing the issue with the linked StackBlitz example, using src/routes/layout.tsx and src/routes/index.tsx, then trace the Qwik runtime and router patching triggered by the routeAction$ submission when a layout is present. Verify the fix by confirming the dialog remains open, matches ':modal', and retains its backdrop after the action resolves.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- frontend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100