Skip to content

fix(frontend): harden module lifecycle, dialogs, and build tooling - #4

Open
corneliusio wants to merge 14 commits into
mainfrom
dev/misc
Open

fix(frontend): harden module lifecycle, dialogs, and build tooling#4
corneliusio wants to merge 14 commits into
mainfrom
dev/misc

Conversation

@corneliusio

@corneliusio corneliusio commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Tear down and replace module bindings when scopes are re-initialized.
  • Improve modal focus management and migrate the lightbox to native dialogs.
  • Fix image dimension fallbacks, slide transition flushing, and navigation eager loading.
  • Harden Vite, Tinify, installer, environment handling, and formatting configuration.
  • Add initialization and lightbox regression coverage.

Testing

  • Added Vitest coverage for module re-initialization and lightbox dialog behavior.
  • Added jsdom dialog method setup for test execution.
  • Not run: repository test, lint, typecheck, or build commands.

Note

Harden module lifecycle, dialog focus trapping, and build tooling

  • init.ts now tracks module bindings per scope with a WeakMap, cancels stale pending loads, and tears down modules when their elements disappear — re-initialization replaces bindings instead of stacking them.
  • Modal.svelte improves focus trapping to handle elements outside the dialog, restores focus to the previously focused element on close, and adds an aria-label via a new label prop.
  • lightbox.ts is reworked to use a native <dialog> element with showModal()/close(), closing on backdrop click or cancel event and restoring focus to the trigger on close.
  • vite-plugin-tinify.ts now requires an API key via options and caches image checksums under node_modules/.vite/tinify/ to skip redundant optimizations.
  • slide.ts forces a layout flush before starting transitions so slide-up and slide-down animations start from a stable measured height.
  • Behavioral Change: craft in global.ts now validates window.$app lazily on first property access rather than at module initialization; config/vite.php restricts the Vite dev server to the dev environment only.

Macroscope summarized 2058b7b.

Triggers were set to type="submit" (an inverted typo for "button"), so a
lightbox button inside any form opened the lightbox and submitted the
form. Also only set the type on actual button elements.
Start and end heights were written in the same synchronous block, so the
browser only ever saw auto -> 0 / auto -> Npx, which is not interpolable
and made slideUp/slideDown jump instantly. Force a reflow between the
writes to give the transition an interpolable origin.
- Only fall back to the Vite dev server in the dev environment; a
  missing manifest in staging/production now fails loudly instead of
  serving every asset from the dev server port.
- Refuse to run the pre-build rmSync when VITE_BASE/VITE_TEMP resolve to
  an empty path, which would have emptied the webroot.
- Pass TINYPNG_KEY into the tinify plugin from the validated env parse;
  loadEnv() never populates process.env, so the plugin would have thrown
  on the first raster image in a bundle.
- Hash raw asset bytes for the tinify cache checksum instead of a lossy
  UTF-8 decode, which could collide across distinct images and serve the
  wrong cached file. Existing cache entries re-tinify once.
The strict AppSchema parse ran at module init, so any mismatch in the
Twig-emitted $app object threw while importing nearly every module and
killed all page JS. The parse (still strict) now runs on first property
access behind a memoized proxy, scoping a failure to the code that
actually reads craft, and reports the offending paths via prettifyError.
The dialog was opened via the open attribute, so there was no top
layer, focus trap, or inert background, and the Tab handler navigated
images without preventDefault, walking focus through the obscured page.
Use showModal()/close() for native modal semantics, move the nav
buttons (now labeled) inside the dialog, navigate with arrow keys only,
restore focus to the trigger on close, and style the native ::backdrop
with a defined color in place of the undefined brand token.

jsdom ships HTMLDialogElement without its methods, so add a minimal
test-only polyfill via a vitest setup file.
The focus trap cached its focusable list at mount (missing async
children) and its selector omitted iframes and media controls, so a
video modal looped Tab on the close button forever. Query focusables at
keydown time with a broadened selector, gate key handling on the active
modal id, recapture focus that escapes the dialog, fire onclose when
another modal replaces this one, restore focus to the previously
focused element on close, and add a label prop for aria-label.

Also replace undefined brand-* color utilities with neutral built-ins
in Modal and Video.
Sources with null or partial transform args rendered width="0"
height="0" images and baked zeroed dimensions into the 2x srcset URL.
Fall back to the asset intrinsic size for the attributes and only
double dimensions that were actually provided, matching
GeneralExtension::imageAttributes().
Desktop and mobile menus each lazy-loaded navigation.main and every
dropdown item lazy-loaded item.links, costing roughly 2 + 2N queries
per uncached request. Compute the menu once with eagerly(), pass it to
the mobile include explicitly, and batch child links.
Non-matching environments received the markup wrapped in a template
element, so dev-only block comments (type handles and element ids)
shipped in production HTML.
Add a bash shebang (the script uses bash-only syntax but ran under sh
via the POSIX fallback, failing on Debian-family systems) and set
-euo pipefail so a signed-out 1Password CLI aborts instead of silently
writing an empty Tinify key. Quote substitutions and stop clobbering an
existing .env on re-run.
pnpm falls back to a project-local store when running inside the ddev
container, since it cannot hard-link across the project mount boundary.
init discarded the cleanup functions modules return, so re-running
init over the same scope (e.g. after a Sprig/htmx swap) double-bound
everything: a second lightbox dialog and keydown listener, self-
canceling play/pause clicks, stacked parallax scroll handlers. Track
cleanups per scope and selector in a WeakMap, run them before
re-binding, and tear down bindings whose elements are gone.

Cleanups only run on re-initialization of the same scope; fragment
scopes that are discarded without a further init call still leak
their bindings until an explicit destroy() counterpart exists.
@greptile-apps

greptile-apps Bot commented Aug 8, 2026

Copy link
Copy Markdown

Greptile Summary

The PR hardens frontend module lifecycle handling, native-dialog behavior, media rendering, navigation loading, environment configuration, and build safeguards.

  • Tracks module bindings per scope and prevents superseded dynamic imports from initializing stale elements.
  • Migrates lightbox behavior to a native dialog and improves modal focus, keyboard, and scroll management.
  • Adds image-dimension fallbacks, transition flushing, eager navigation loading, and safer build/install configuration.
  • Adds regression coverage for module reinitialization and lightbox dialog behavior.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
src/lib/init.ts Adds per-scope lifecycle generations and cleanup replacement; the current pass guard fixes the previously reported stale-import initialization race.
tests/init.test.ts Covers repeated initialization, superseding an unresolved import with an empty scope, and teardown after element removal.
src/lib/modules/lightbox.ts Replaces the manual backdrop with a native dialog and updates navigation, dismissal, focus restoration, and cleanup behavior.
src/lib/components/common/Modal.svelte Improves focus discovery and trapping, active-modal close handling, scroll locking, labeling, and focus restoration.
vite.config.ts Passes validated Tinify configuration explicitly and prevents builds from emptying the webroot.
src/lib/util/image.ts Avoids zero-valued doubled transforms and falls back to intrinsic image dimensions.

Sequence Diagram

sequenceDiagram
    participant Caller
    participant Init
    participant Binding
    participant Import
    participant Module
    Caller->>Init: init(scope)
    Init->>Binding: increment selector pass
    Init->>Import: request module
    Caller->>Init: init(scope) again
    Init->>Binding: increment pass again
    Import-->>Init: earlier request resolves
    Init->>Binding: compare captured pass
    Init-->>Import: discard superseded result
    Import-->>Init: latest request resolves
    Init->>Binding: run previous cleanup
    Init->>Module: initialize current elements
    Module-->>Binding: store cleanup
Loading

Reviews (2): Last reviewed commit: "fix(init): prevent a superseded pass fro..." | Re-trigger Greptile

Comment thread src/lib/init.ts Outdated
A re-initialization that found no matching elements ran its teardown
synchronously, so an earlier pass whose dynamic import was still in
flight would land afterward and bind its captured, now-detached
elements anyway, installing global artifacts with no owner. Track a
per-scope, per-selector pass counter and have the async continuation
bail when it has been superseded, so only the latest pass ever binds.
@corneliusio

Copy link
Copy Markdown
Contributor Author

@greptile

corneliusio added a commit to mostlyserious/craftcms-sveltekit that referenced this pull request Aug 12, 2026
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