diff --git a/news/7317.bugfix.md b/news/7317.bugfix.md new file mode 100644 index 00000000000..c9c05a4ed9f --- /dev/null +++ b/news/7317.bugfix.md @@ -0,0 +1 @@ +Fix stylesheet edits in `assets/` not applying in dev mode until a manual reload. The global stylesheet `` is now emitted only in production builds, so Vite's CSS hot update swaps the real stylesheet link again. diff --git a/reflex/compiler/utils.py b/reflex/compiler/utils.py index 4b71ebd36a2..abaeac06187 100644 --- a/reflex/compiler/utils.py +++ b/reflex/compiler/utils.py @@ -43,6 +43,7 @@ from reflex.istate.storage import Cookie, LocalStorage, SessionStorage from reflex.state import BaseState from reflex.utils import path_ops +from reflex.utils.exec import is_prod_mode from reflex.utils.prerequisites import get_web_dir if TYPE_CHECKING: @@ -796,11 +797,21 @@ def create_document_root( } ), ) - # Always include the framework meta and link tags. + # Always include the framework meta and link tags. The preload hint is a + # production-only optimization: in dev, Vite's css-update swaps the first + # link matching the stylesheet path, so it must be the stylesheet link. always_head_components = [ ReactMeta.create(), - Link.create( - rel="preload", custom_attrs={"as": "style"}, href=global_styles_href + *( + [ + Link.create( + rel="preload", + custom_attrs={"as": "style"}, + href=global_styles_href, + ) + ] + if is_prod_mode() + else [] ), Link.create( rel="stylesheet", diff --git a/tests/units/compiler/test_compiler.py b/tests/units/compiler/test_compiler.py index 36ec3114aef..25a326ebb6c 100644 --- a/tests/units/compiler/test_compiler.py +++ b/tests/units/compiler/test_compiler.py @@ -761,6 +761,17 @@ def test_compile_nonexistent_stylesheet(tmp_path, mocker: MockerFixture): compiler.compile_root_stylesheet(stylesheets) +@pytest.fixture +def dev_mode(monkeypatch: pytest.MonkeyPatch) -> None: + """Pin dev mode, whose document head omits the stylesheet preload. + + Args: + monkeypatch: Selects dev mode and restores the previous mode afterwards. + """ + monkeypatch.setenv("REFLEX_ENV_MODE", constants.Env.DEV.value) + + +@pytest.mark.usefixtures("dev_mode") def test_create_document_root(): """Test that the document root is created correctly.""" # Test with no components. @@ -773,7 +784,7 @@ def test_create_document_root(): assert isinstance(lang, LiteralStringVar) assert lang.equals(Var.create("en")) # No children in head. - assert len(root.children[0].children) == 7 + assert len(root.children[0].children) == 6 assert isinstance(root.children[0].children[1], Meta) char_set = root.children[0].children[1].char_set # pyright: ignore [reportAttributeAccessIssue] assert isinstance(char_set, LiteralStringVar) @@ -784,8 +795,7 @@ def test_create_document_root(): assert name.equals(Var.create("viewport")) assert isinstance(root.children[0].children[3], document.Meta) assert isinstance(root.children[0].children[4], Link) - assert isinstance(root.children[0].children[5], Link) - assert isinstance(root.children[0].children[6], Links) + assert isinstance(root.children[0].children[5], Links) def test_add_meta_accepts_dynamic_description(): @@ -822,6 +832,7 @@ def test_add_meta_drops_empty_description(): assert not any(isinstance(child, Description) for child in page.children) +@pytest.mark.usefixtures("dev_mode") def test_create_document_root_with_scripts(): # Test with components. comps = [ @@ -834,7 +845,7 @@ def test_create_document_root_with_scripts(): html_custom_attrs={"project": "reflex"}, ) assert isinstance(root, Html) - assert len(root.children[0].children) == 9 + assert len(root.children[0].children) == 8 names = [c.tag for c in root.children[0].children] assert names == [ "script", @@ -844,7 +855,6 @@ def test_create_document_root_with_scripts(): "meta", "Meta", "link", - "link", "Links", ] lang = root.lang # pyright: ignore [reportAttributeAccessIssue] @@ -854,6 +864,7 @@ def test_create_document_root_with_scripts(): assert root.custom_attrs == {"project": "reflex"} +@pytest.mark.usefixtures("dev_mode") def test_create_document_root_with_meta_char_set(): # Test with components. comps = [ @@ -863,12 +874,13 @@ def test_create_document_root_with_meta_char_set(): head_components=comps, ) assert isinstance(root, Html) - assert len(root.children[0].children) == 7 + assert len(root.children[0].children) == 6 names = [c.tag for c in root.children[0].children] - assert names == ["script", "meta", "meta", "Meta", "link", "link", "Links"] + assert names == ["script", "meta", "meta", "Meta", "link", "Links"] assert str(root.children[0].children[1].char_set) == '"cp1252"' # pyright: ignore [reportAttributeAccessIssue] +@pytest.mark.usefixtures("dev_mode") def test_create_document_root_with_meta_viewport(): # Test with components. comps = [ @@ -879,9 +891,9 @@ def test_create_document_root_with_meta_viewport(): head_components=comps, ) assert isinstance(root, Html) - assert len(root.children[0].children) == 8 + assert len(root.children[0].children) == 7 names = [c.tag for c in root.children[0].children] - assert names == ["script", "meta", "meta", "meta", "Meta", "link", "link", "Links"] + assert names == ["script", "meta", "meta", "meta", "Meta", "link", "Links"] assert str(root.children[0].children[1].http_equiv) == '"refresh"' # pyright: ignore [reportAttributeAccessIssue] assert str(root.children[0].children[2].name) == '"viewport"' # pyright: ignore [reportAttributeAccessIssue] assert str(root.children[0].children[2].content) == '"foo"' # pyright: ignore [reportAttributeAccessIssue] diff --git a/tests/units/compiler/test_compiler_utils.py b/tests/units/compiler/test_compiler_utils.py index a5effc98be7..d3c7c7a2ff3 100644 --- a/tests/units/compiler/test_compiler_utils.py +++ b/tests/units/compiler/test_compiler_utils.py @@ -4,6 +4,7 @@ from copy import deepcopy import pytest +from reflex_base import constants from reflex_base.components.memo import create_passthrough_component_memo from reflex_base.registry import RegistrationContext from reflex_components_core.base.fragment import Fragment @@ -126,12 +127,28 @@ def test_restore_bundled_libraries_ignores_missing_artifact( utils._restore_bundled_libraries() -def test_document_preloads_the_global_stylesheet(): - """Render-blocking CSS should be discoverable alongside early resource hints.""" +def _global_stylesheet_links() -> list[list[str]]: + """Render the framework link tags of a fresh document head. + + Returns: + The rendered props of every ``Link`` in the head. + """ head = create_document_root().children[0] - links = [ + return [ child.render()["props"] for child in head.children if isinstance(child, Link) ] + + +def test_document_preloads_the_global_stylesheet_in_prod( + monkeypatch: pytest.MonkeyPatch, +): + """Production builds hint the render-blocking CSS ahead of the stylesheet link. + + Args: + monkeypatch: Selects prod mode and restores the previous mode afterwards. + """ + monkeypatch.setenv("REFLEX_ENV_MODE", constants.Env.PROD.value) + links = _global_stylesheet_links() preload = next(props for props in links if 'rel:"preload"' in props) stylesheet = next(props for props in links if 'rel:"stylesheet"' in props) assert next(prop for prop in preload if prop.startswith("href:")) == next( @@ -140,6 +157,20 @@ def test_document_preloads_the_global_stylesheet(): assert 'as:"style"' in preload +def test_document_does_not_preload_the_global_stylesheet_in_dev( + monkeypatch: pytest.MonkeyPatch, +): + """Dev builds link the stylesheet once so Vite's css-update swaps that link. + + Args: + monkeypatch: Selects dev mode and restores the previous mode afterwards. + """ + monkeypatch.setenv("REFLEX_ENV_MODE", constants.Env.DEV.value) + links = _global_stylesheet_links() + assert not any('rel:"preload"' in props for props in links) + assert sum('rel:"stylesheet"' in props for props in links) == 1 + + class CompileStateState(State): """State fixture exercising async computed vars during compile_state."""