QwikDev / QwikDev/qwik

[🐞] V2 Native `<dialog>` loses top-layer membership after `routeAction$` submit when wrapped in layout and uses a loader

Open
#8,694 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug runtime
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 (the open attribute is still there)
  • dialog.matches(':modal') === false (top-layer membership is gone)
  • No close event fires
  • No JS in the app calls .close()
  • ::backdrop no 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
  1. Visit the route.
  2. Click Open dialog — modal appears centered with backdrop. Status shows
    open=true isModal=true.
  3. Click Submit inside the dialog.
  4. Modal visually disappears. Status now shows
    open=true isModal=false.
  5. 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:

  • open attribute remains true
  • :modal is false
  • ::backdrop no longer renders
  • Dialog renders in normal flow, appearing "closed" to the user
  • No close event 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

https://stackblitz.com/edit/vitejs-vite-avra2s5b?file=src%2Froutes%2Findex.tsx,src%2Froutes%2Flayout.tsx&terminal=dev

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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.