Metadata-Version: 2.4
Name: dash-startup-loading-plugin
Version: 0.2.0
Summary: A configurable full-screen startup loading overlay for Dash apps, packaged as a Dash Hooks plugin.
Author-email: Ethan Zhang <ethan.zhang2016@gmail.com>
License-Expression: MIT
Keywords: dash,plotly,loading,plugin,hooks
Classifier: Framework :: Dash
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: dash>=3.0.3
Provides-Extra: test
Requires-Dist: pytest>=8; extra == "test"
Dynamic: license-file

# dash-startup-loading-plugin

`dash-startup-loading-plugin` is an installable [Dash Hooks plugin](https://dash.plotly.com/dash-plugins-using-hooks)
that displays a full-screen loading overlay while a Dash application performs
its initial browser-side startup.

The overlay, CSS, and JavaScript are injected into the final HTML document with
`hooks.index`, so they are available before Dash and React mount the application
layout without additional startup resource requests. An app does not need to
copy assets or replace Dash's `index_string`.

## Features

- Appears before the Dash renderer starts, avoiding a blank startup page.
- Is discovered automatically through Dash's `dash_hooks` entry point.
- Waits for real rendered content and optional app-specific readiness selectors.
- Can wait for lazy-loading placeholders to disappear.
- Includes a timeout fallback, minimum display time, and fade-out transition.
- Inlines its small startup CSS and JavaScript, avoiding two additional requests.
- Resolves light and dark themes before first paint from Dash component
  persistence, Tailwind root classes, Mantine color-scheme state, or the
  operating-system preference.
- Exposes a small browser API and emits a completion event.
- Requires no changes to the app layout or callbacks.

## Requirements

- Python 3.9 or later
- Dash 3.0.3 or later

Dash Hooks were introduced in Dash 3.0. The plugin uses automatic hook
discovery, resource hooks, and the index hook described in the official
[Dash plugin documentation](https://dash.plotly.com/dash-plugins-using-hooks).

## Installation

```bash
pip install "dash-startup-loading-plugin>=0.2.0"
```

The package declares the following entry point:

```toml
[project.entry-points."dash_hooks"]
dash_startup_loading_plugin = "dash_startup_loading_plugin"
```

Dash imports registered `dash_hooks` packages automatically. Installing the
package therefore enables the default startup overlay for Dash applications in
that Python environment; an explicit import is only needed when changing its
configuration or using its Python API.

## What's new in 0.2.0

- Resolves the loading background before first paint from a persisted
  `dash_antd_components.ConfigProvider.theme`.
- Supports Tailwind's `html.dark` and `html.light` classes.
- Supports Dash Mantine Components' `data-mantine-color-scheme` attribute and
  `mantine-color-scheme-value` localStorage entry.
- Adds `theme_mode` and `dash_theme_component_id` configuration.
- Keeps the startup CSS and JavaScript inline, so theme detection does not add
  network requests before Dash mounts.

## Quick start

The defaults work without any plugin-specific code:

```python
from dash import Dash, html

app = Dash(__name__)
app.layout = html.Main(
    [
        html.H1("My Dash app"),
        html.P("The overlay disappears after this layout is mounted."),
    ]
)

if __name__ == "__main__":
    app.run(debug=True)
```

To customize the readiness conditions or appearance, call `configure()` before
creating the `Dash` instance:

```python
from dash import Dash, html
from dash_startup_loading_plugin import configure

configure(
    required_selectors=["#page-header", "#sidebar-menu"],
    pending_selector="[data-dac-async-placeholder]",
    timeout_ms=6000,
    minimum_display_ms=200,
    fade_duration_ms=160,
    color="#1677ff",
    dark_color="#4096ff",
)

app = Dash(__name__)
app.layout = html.Div(
    [
        html.Header("Dashboard", id="page-header"),
        html.Nav("Navigation", id="sidebar-menu"),
    ]
)
```

See [`examples/basic.py`](examples/basic.py) for a runnable example.

## Readiness behavior

The overlay closes with the `ready` reason after all of these conditions are
true:

1. `root_selector` exists.
2. The root no longer contains Dash's initial `._dash-loading` element.
3. The root contains an element or non-empty text node.
4. Every CSS selector in `required_selectors` exists in the document.
5. No node matching `pending_selector` remains under the root.
6. The conditions stay true for two consecutive animation frames.

A `MutationObserver` rechecks these conditions as the page changes. The
two-frame confirmation prevents the overlay from disappearing during an
intermediate render.

`timeout_ms` is a safety fallback and dismisses the overlay even when the
readiness contract is not satisfied. Set it to `None` to disable forced
dismissal. A timeout is not delayed by `minimum_display_ms`.

## Configuration reference

`configure(**changes)` updates only the supplied values and returns the active,
immutable `StartupLoadingConfig` instance.

| Option | Default | Description |
|---|---:|---|
| `enabled` | `True` | Inject the startup overlay. Set to `False` to disable it. |
| `overlay_id` | `"dash-startup-loading"` | HTML `id` of the injected overlay; also used by the browser API. |
| `aria_label` | `"Loading"` | Accessible label on the overlay's `role="status"` element. |
| `root_selector` | `"#react-entry-point"` | Dash renderer root observed for mounted content. |
| `required_selectors` | `("#react-entry-point",)` | Iterable of document-level CSS selectors that must all exist. A single string is not accepted. |
| `pending_selector` | `"[data-dac-async-placeholder]"` | Selector for placeholders under the root that delay dismissal. Use `None` to disable this check. |
| `timeout_ms` | `6000` | Forced-dismiss timeout in milliseconds. Use `None` to disable it. |
| `minimum_display_ms` | `0` | Minimum display time for ready or manual dismissal. |
| `fade_duration_ms` | `160` | Opacity transition duration before the overlay is removed. |
| `z_index` | `9999` | Overlay stacking order. |
| `background` | `"#ffffff"` | Light-theme background color. |
| `dark_background` | `"#0f0f0f"` | Dark-theme background color. |
| `color` | `"#1677ff"` | Light-theme spinner/current color. |
| `dark_color` | `"#4096ff"` | Dark-theme spinner/current color. |
| `theme_mode` | `"auto"` | Startup theme policy: `"auto"`, `"light"`, or `"dark"`. Auto follows persisted or active app state, then the operating-system preference. |
| `dash_theme_component_id` | `None` | Preferred Dash component ID when reading a persisted `theme` prop. When omitted, the first compatible persisted theme is used. |
| `spinner_size_px` | `28` | Default spinner width and height in pixels. |
| `spinner_stroke_px` | `3` | Default spinner stroke width in pixels. |
| `hide_default_loading` | `True` | Hide Dash's built-in initial `._dash-loading` indicator while the overlay is present. |
| `custom_loader_html` | `None` | Trusted HTML that replaces the default spinner. |

With `theme_mode="auto"`, the inline head bootstrap resolves the startup theme
in this order:

1. The persisted Dash `theme` for `dash_theme_component_id`, when configured.
2. An existing `html.dark` or `html.light` class, including Tailwind's manual
   dark-mode convention.
3. `data-theme`, `data-color-scheme`, or `data-mantine-color-scheme` on
   `<html>`.
4. Other Dash local persistence entries for a component's `theme` prop. Set
   `dash_theme_component_id` when an app contains more than one persisted
   theme-capable component.
5. Dash Mantine Components' `mantine-color-scheme-value` localStorage entry,
   followed by the common `theme`, `color-theme`, and `color-scheme` keys.
6. `prefers-color-scheme`.

The result is written to `data-dash-startup-loading-theme` before the body is
parsed. This isolates the overlay styling without changing classes or data
attributes owned by Tailwind, Dash Mantine Components, or the application.
The stylesheet also adjusts its animation for `prefers-reduced-motion`.

### Dash Ant Design persistence

`dash_antd_components.ConfigProvider` can persist its `theme` prop. The startup
plugin reads that same Dash persistence record on the next page load:

```python
import dash_antd_components as dac
from dash_startup_loading_plugin import configure

configure(dash_theme_component_id="theme-provider")

provider = dac.ConfigProvider(
    app_content,
    id="theme-provider",
    persistence=True,
    persisted_props=["theme"],
    persistence_type="local",
)
```

Update `theme-provider.theme` from a regular or clientside callback. Dash stores
the UI edit, `ConfigProvider` restores it after mount, and the startup plugin
uses it for the loading background before mount. See
[`examples/basic.py`](examples/basic.py) for the complete switcher.

### Custom loader markup

```python
from dash_startup_loading_plugin import configure

configure(
    aria_label="Loading dashboard",
    custom_loader_html="""
    <div class="brand-loader" aria-hidden="true">
      <span></span><span></span><span></span>
    </div>
    """,
)
```

Add the matching `.brand-loader` rules to the app's normal `assets` directory.
`custom_loader_html` is inserted verbatim and must be trusted application
configuration. Never populate it with user input.

## Python API

```python
from dash_startup_loading_plugin import (
    StartupLoadingConfig,
    configure,
    get_config,
    reset_config,
)
```

- `configure(**changes)` validates and applies a partial configuration update.
- `get_config()` returns the current immutable configuration.
- `reset_config()` restores all defaults. It is primarily useful in tests.
- `StartupLoadingConfig` is the frozen dataclass containing all options.

Unknown option names raise `TypeError`. Invalid selector collections and
negative timing or spinner values are rejected rather than silently ignored.

## Browser API and events

The plugin exposes two methods for integrations that need explicit control:

```javascript
// Recheck the configured readiness conditions.
window.dashStartupLoading.check();

// Begin a manual dismissal.
window.dashStartupLoading.finish();

// A custom overlay_id can be supplied to either method.
window.dashStartupLoading.finish("my-loading-overlay");
```

Before fading out, the overlay dispatches a bubbling
`dash-startup-loading:ready` event. Its `detail.reason` is `"ready"`,
`"timeout"`, or `"manual"`:

```javascript
document.addEventListener("dash-startup-loading:ready", function (event) {
    console.log("Startup overlay finished:", event.detail.reason);
});
```

## Migrating from a custom `index_string`

If an app previously injected a loader into a hand-written `index_string`, move
its readiness contract into the plugin and remove the `app.index_string = ...`
assignment:

```python
from dash_startup_loading_plugin import configure

configure(
    required_selectors=["#usage-header", "#usage-sidebar-menu"],
    pending_selector="[data-dac-async-placeholder]",
    timeout_ms=6000,
    fade_duration_ms=160,
)
```

The plugin preserves Dash's normal index template, injects its stylesheet before
`</head>`, and inserts the overlay followed by its startup script directly after
the opening `<body>` tag. The source CSS and JavaScript remain package resources
for maintainability, but the browser receives them inline and makes no separate
request for either file. The index hook uses priority `100`, so it runs before
lower-priority index hooks. If multiple hooks share the same priority, Dash does
not guarantee their relative order.

Because the resources are inline, applications with a strict Content Security
Policy must permit the injected style and script, or add nonce support in a
customized deployment. The previous external-resource form is more suitable
when a policy forbids all inline code.

## Scope and process model

Dash's hook registry is process-wide. `configure()` therefore affects every
Dash app created in the same Python process. Use one shared configuration per
process, or set `enabled=False` when the overlay should not be injected.

This plugin handles initial application startup only. It does not show a
full-screen overlay for later callback execution; use `dcc.Loading` or another
callback-specific loading pattern for that use case.

## Troubleshooting

### The overlay only disappears after the timeout

One of the configured selectors is probably never becoming ready. Check that:

- `root_selector` matches the actual Dash renderer root.
- Every `required_selectors` entry exists in the final document.
- `pending_selector` does not match a placeholder that remains permanently.
- Custom selectors are valid CSS selectors.

Temporarily keep the fallback enabled and listen for the completion event. A
`"timeout"` reason confirms that the readiness contract was not met.

### The overlay disappears too quickly

Set `minimum_display_ms` to keep it visible for a predictable minimum duration,
or add stable application elements to `required_selectors`.

### The overlay appears in every app in the environment

This is expected with automatic `dash_hooks` discovery. Use a dedicated virtual
environment, uninstall the package where it is not wanted, or call
`configure(enabled=False)` before constructing those Dash apps.

## Development

Clone the repository, then install the project and test dependencies:

```bash
uv sync --extra test
```

Run the tests and example:

```bash
uv run pytest
uv run --with dash-ant-design python examples/basic.py
```

The example uses `dash_antd_components.ConfigProvider` to demonstrate native
theme persistence, so `dash-ant-design` is an example-only dependency rather
than a runtime dependency of the plugin.

Build the source distribution and wheel:

```bash
uv build
```

## Publishing to PyPI

PyPI does not allow replacing an existing distribution file. Confirm that the
version in `pyproject.toml` is new before building, then create the artifacts
from a clean `dist/` directory:

```bash
rm -rf build dist src/*.egg-info
uv lock
uv run --extra test pytest
uvx ruff check src tests examples
uv build
uvx twine check dist/*
```

Inspect the resulting files before upload:

```bash
ls -lh dist/
unzip -l dist/dash_startup_loading_plugin-0.2.0-py3-none-any.whl
tar -tzf dist/dash_startup_loading_plugin-0.2.0.tar.gz
uv publish --dry-run dist/*
```

### TestPyPI

Create a TestPyPI API token, then upload to its legacy upload endpoint:

```bash
export UV_PUBLISH_TOKEN="pypi-your-test-token"
uv publish \
  --publish-url https://test.pypi.org/legacy/ \
  --check-url https://test.pypi.org/simple/ \
  dist/*
unset UV_PUBLISH_TOKEN
```

Test installation in an isolated environment:

```bash
uv run --isolated \
  --index-url https://test.pypi.org/simple/ \
  --extra-index-url https://pypi.org/simple/ \
  --with dash-startup-loading-plugin==0.2.0 \
  python -c "import dash_startup_loading_plugin; print('import ok')"
```

The extra PyPI index is needed because TestPyPI may not contain the plugin's
runtime dependencies.

### PyPI with an API token

Create a project-scoped PyPI token and publish the checked artifacts:

```bash
export UV_PUBLISH_TOKEN="pypi-your-project-token"
uv publish dist/*
unset UV_PUBLISH_TOKEN
```

Do not place tokens in `pyproject.toml`, shell history, source control, or CI
logs.

### PyPI Trusted Publishing

For CI releases, configure the repository and workflow as a Trusted Publisher
in the PyPI project settings. The publishing job needs the
`id-token: write` permission and can upload without a long-lived token:

```yaml
permissions:
  contents: read
  id-token: write

steps:
  - uses: actions/checkout@v4
  - uses: astral-sh/setup-uv@v8
  - run: uv build
  - run: uv publish --trusted-publishing always
```

After publishing, verify the release:

```bash
uv run --isolated \
  --with dash-startup-loading-plugin==0.2.0 \
  python -c "import dash_startup_loading_plugin; print('import ok')"
```

## License

MIT. See [`LICENSE`](LICENSE).
