Repository navigation
Expand file tree
/
Copy pathtest_memoize_plugin.py
More file actions
3056 lines (2563 loc) ยท 117 KB
/
Copy pathtest_memoize_plugin.py
File metadata and controls
3056 lines (2563 loc) ยท 117 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# ruff: noqa: D101
import dataclasses
import re
from collections.abc import Callable
from pathlib import Path
from types import SimpleNamespace
from typing import Any, cast
import pytest
from reflex_base.components.component import Component
from reflex_base.components.component import field as component_field
from reflex_base.components.memo import (
MemoComponent,
MemoComponentDefinition,
MemoParamKind,
create_passthrough_component_memo,
)
from reflex_base.components.memoize_helpers import (
MemoizationStrategy,
get_memoization_strategy,
)
from reflex_base.constants.compiler import MemoizationDisposition, MemoizationMode
from reflex_base.plugins import CompileContext, CompilerHooks, PageContext
from reflex_base.utils import memo_paths
from reflex_base.utils.imports import ImportVar
from reflex_base.vars import VarData
from reflex_base.vars.base import Field, LiteralVar, Var, field
from reflex_components_core.base.bare import Bare
from reflex_components_core.base.fragment import Fragment
from reflex_components_core.base.link import RawLink, ScriptTag
from reflex_components_core.core.foreach import Foreach
from reflex_components_core.el.elements.forms import BaseInput, Textarea
from reflex_components_core.el.elements.inline import Br, Wbr
from reflex_components_core.el.elements.media import (
Area,
Desc,
Embed,
Img,
Source,
SvgStyle,
Track,
)
from reflex_components_core.el.elements.media import Script as SvgScript
from reflex_components_core.el.elements.media import Title as SvgTitle
from reflex_components_core.el.elements.metadata import Base, Link, Meta, StyleEl, Title
from reflex_components_core.el.elements.scripts import Noscript, Script
from reflex_components_core.el.elements.tables import Col
from reflex_components_core.el.elements.typography import Hr
from reflex_components_moment.moment import Moment
from reflex_components_radix.themes.layout.box import Box
import reflex as rx
import reflex.compiler.plugins.memoize as memoize_plugin
from reflex.compiler.plugins import DefaultCollectorPlugin, default_page_plugins
from reflex.compiler.plugins.memoize import MemoizeStatefulPlugin, _should_memoize
from reflex.state import BaseState
STATE_VAR = LiteralVar.create("value")._replace(
merge_var_data=VarData(hooks={"useTestState": None}, state="TestState")
)
class Plain(Component):
tag = "Plain"
library = "plain-lib"
class WithProp(Component):
tag = "WithProp"
library = "with-prop-lib"
label: Var[str] = component_field(default=LiteralVar.create(""))
class LeafComponent(Component):
tag = "LeafComponent"
library = "leaf-lib"
_memoization_mode = MemoizationMode(recursive=False)
class SnapshotWithSlot(Component):
tag = "SnapshotWithSlot"
library = "snapshot-with-slot-lib"
_memoization_mode = MemoizationMode(recursive=False)
slot: Component | None = component_field(default=None)
class MemoAppWrapProvider(Component):
tag = "MemoAppWrapProvider"
library = "memo-app-wrap-provider-lib"
class ChildrenViaProp(Component):
"""Stub mirroring ``CodeBlock`` โ injects its content as ``children`` prop."""
tag = "ChildrenViaProp"
library = "children-via-prop-lib"
code: Var[str] = component_field(default=LiteralVar.create(""))
def _render(self):
return super()._render().remove_props("code").add_props(children=self.code)
class HookGeneratedProp(Component):
"""Component whose hook collection fills a prop needed by render output."""
tag = "HookGeneratedProp"
library = "hook-generated-prop-lib"
label: Var[str] = component_field(default=LiteralVar.create(""))
callback: Var[Any] = component_field(default=LiteralVar.create(None))
def add_hooks(self) -> list[str]:
"""Add a hook and wire its identifier into a component prop.
Returns:
The hook lines this component contributes.
"""
self.callback = Var(_js_expr="generatedCallback")
return ["function generatedCallback(){ return true; }"]
class SpecialFormMemoState(BaseState):
items: Field[list[str]] = field(default_factory=lambda: ["a"])
flag: Field[bool] = field(default=True)
value: Field[str] = field(default="a")
class MemoTriggerState(BaseState):
@rx.event
def ping(self):
"""No-op handler for event-trigger memoization tests."""
@dataclasses.dataclass(slots=True)
class FakePage:
route: str
component: Callable[[], Component]
title: Any = None
description: Any = None
image: str = ""
meta: tuple[dict[str, Any], ...] = ()
_source_module: str | None = None
def _compile_single_page(
component_factory: Callable[[], Component],
) -> tuple[CompileContext, PageContext]:
ctx = CompileContext(
pages=[FakePage(route="/p", component=component_factory)],
hooks=CompilerHooks(plugins=default_page_plugins()),
)
with ctx:
ctx.compile()
return ctx, ctx.compiled_pages["/p"]
def test_should_memoize_catches_direct_state_var_in_prop() -> None:
"""A component whose own prop carries state VarData should memoize."""
comp = WithProp.create(label=STATE_VAR)
assert _should_memoize(comp)
def test_should_not_memoize_state_var_in_child_bare() -> None:
"""A component whose Bare child contains state VarData should memoize."""
comp = Plain.create(STATE_VAR)
assert not _should_memoize(comp)
def test_should_not_memoize_plain_component() -> None:
"""A component with no state vars and no event triggers is not memoized."""
comp = Plain.create(LiteralVar.create("static-content"))
assert not _should_memoize(comp)
def test_should_memoize_state_var_in_child_cond() -> None:
"""A Bare containing state VarData should memoize."""
comp = Bare.create(STATE_VAR)
assert _should_memoize(comp)
def test_should_not_memoize_prop_var_with_imports_only_var_data() -> None:
"""Prop Vars carrying only imports (no state/hooks) must not trigger memoize.
Regression: a ``class_name`` produced by the ``cn`` helper (clsx-for-tailwind)
has VarData with non-empty ``imports`` but empty ``state`` and ``hooks``;
snapshot-boundary elements like ``<textarea>`` were being wrapped in memo
purely because of that helper import.
"""
from reflex_base.utils.imports import ImportVar
import_only_var = LiteralVar.create("static-class")._replace(
merge_var_data=VarData(
imports={"clsx-for-tailwind": [ImportVar(tag="cn")]},
)
)
comp = WithProp.create(label=import_only_var)
assert not _should_memoize(comp)
# Snapshot-boundary form of the same: a Textarea whose only stateful-looking
# signal is an import-bearing class_name should not be memoized either.
boundary = Textarea.create(class_name=import_only_var, name="x")
assert not _should_memoize(boundary)
# And the Bare-contents short-circuit must use the same predicate: a Bare
# wrapping a Var with import-only var_data must not be memoized.
bare = Bare.create(import_only_var)
assert not _should_memoize(bare)
def test_should_not_memoize_when_disposition_never() -> None:
"""``MemoizationDisposition.NEVER`` overrides heuristic eligibility."""
comp = Plain.create(STATE_VAR)
object.__setattr__(
comp,
"_memoization_mode",
dataclasses.replace(
comp._memoization_mode, disposition=MemoizationDisposition.NEVER
),
)
assert not _should_memoize(comp)
def test_memoize_wrapper_uses_memo_component_and_call_site() -> None:
"""Memoizable component imports a generated ``rx.memo`` wrapper."""
ctx, page_ctx = _compile_single_page(lambda: Plain.create(STATE_VAR))
assert len(ctx.memoize_wrappers) == 1
wrapper_tag = next(iter(ctx.memoize_wrappers))
assert (wrapper_tag, None) in ctx.auto_memo_components
output = page_ctx.output_code or ""
assert f'import {{{wrapper_tag}}} from "$/utils/components/{wrapper_tag}"' in output
assert f"jsx({wrapper_tag}," in (page_ctx.output_code or "")
assert f"const {wrapper_tag} = memo" not in output
def test_auto_memo_component_renders_after_add_hooks_mutates_props() -> None:
"""Auto-memo modules render after ``add_hooks`` has filled derived props."""
ctx, _page_ctx = _compile_single_page(
lambda: HookGeneratedProp.create(label=STATE_VAR)
)
memo_code = _compile_memo_module_text(ctx)
assert "function generatedCallback(){ return true; }" in memo_code
assert "callback:generatedCallback" in memo_code
def test_memoize_wrapper_deduped_across_repeated_subtrees() -> None:
"""Two identical memoizable call-sites collapse to one memo definition."""
ctx, page_ctx = _compile_single_page(
lambda: Fragment.create(
Plain.create(STATE_VAR),
Plain.create(STATE_VAR),
)
)
assert len(ctx.memoize_wrappers) == 1
wrapper_tag = next(iter(ctx.memoize_wrappers))
assert list(ctx.auto_memo_components) == [(wrapper_tag, None)]
assert (page_ctx.output_code or "").count(
f'import {{{wrapper_tag}}} from "$/utils/components/{wrapper_tag}"'
) == 1
def test_passthrough_memo_collects_var_app_wraps_from_replaced_component() -> None:
"""Var app_wraps on passthrough-memoized components survive replacement."""
provider = MemoAppWrapProvider.create()
stateful_var_with_wrap = LiteralVar.create("needs-wrap")._replace(
merge_var_data=VarData(
hooks={"useNeedsWrap": None},
app_wraps=((70, provider),),
)
)
_ctx, page_ctx = _compile_single_page(
lambda: WithProp.create(label=stateful_var_with_wrap)
)
assert (70, "MemoAppWrapProvider") in page_ctx.app_wrap_components
def test_snapshot_memo_collects_var_app_wraps_from_prop_components() -> None:
"""Snapshot memo boundaries collect app_wraps buried in prop components."""
provider = MemoAppWrapProvider.create()
var_with_wrap = LiteralVar.create("needs-wrap")._replace(
merge_var_data=VarData(app_wraps=((70, provider),))
)
_ctx, page_ctx = _compile_single_page(
lambda: SnapshotWithSlot.create(
STATE_VAR,
slot=WithProp.create(label=var_with_wrap),
)
)
assert (70, "MemoAppWrapProvider") in page_ctx.app_wrap_components
def test_memoize_wrappers_distinct_for_different_on_mount() -> None:
"""Two components differing only in ``on_mount`` must NOT dedupe.
``on_mount`` is excluded from ``_render``'s props, so its handler does not
appear in ``component.render()``. The memo wrapper body, however, includes
a ``useEffect`` lifecycle hook whose body invokes the ``on_mount`` handler
โ two distinct handlers produce two distinct memo bodies and must compile
to two distinct memo wrappers. Hashing only ``render()`` lets them collide
on a single tag, silently dropping one handler's logic.
"""
ctx, _page_ctx = _compile_single_page(
lambda: Fragment.create(
Plain.create(on_mount=rx.console_log("a")),
Plain.create(on_mount=rx.console_log("b")),
)
)
assert len(ctx.memoize_wrappers) == 2, (
"Components with different on_mount handlers must produce distinct "
f"memo wrappers, got: {list(ctx.memoize_wrappers)}"
)
def test_memoize_wrappers_dedupe_passthrough_with_different_children() -> None:
"""Passthrough memos with identical props but different children must dedupe.
Passthrough memo bodies render a ``{children}`` placeholder rather than
the captured child JSX โ the actual children flow through at the page-side
call site. Two components with identical props but different children
therefore generate identical memo body code and must share one wrapper
tag. Hashing the rendered children into the tag splits them needlessly,
bloating the generated component module with duplicate definitions.
"""
ctx, _page_ctx = _compile_single_page(
lambda: Fragment.create(
WithProp.create("apple", label=STATE_VAR),
WithProp.create("banana", label=STATE_VAR),
)
)
assert len(ctx.memoize_wrappers) == 1, (
"Passthrough memos differing only in children must collapse to one "
f"wrapper, got: {list(ctx.memoize_wrappers)}"
)
@pytest.mark.parametrize(
("special_form", "body_marker"),
[
("foreach", "Array.prototype.map.call"),
],
)
def test_special_form_memo_wrappers_render_structural_body(
special_form: str,
body_marker: str,
) -> None:
"""Generated memo wrappers for special forms render the structural body.
The memo body must subscribe to the state the special form references
(via ``useContext(StateContexts...)``), and the page must not โ otherwise
the state-dependent render has leaked into page scope.
"""
from reflex.compiler.compiler import compile_memo_components
def special_child() -> Component:
if special_form == "foreach":
return rx.foreach(
SpecialFormMemoState.items,
lambda item: rx.text(item),
)
if special_form == "cond":
return cast(
Component,
rx.cond(
SpecialFormMemoState.flag,
rx.text("yes"),
rx.text("no"),
),
)
return cast(
Component,
rx.match(
SpecialFormMemoState.value,
("a", rx.text("A")),
rx.text("default"),
),
)
ctx, page_ctx = _compile_single_page(lambda: rx.box(special_child()))
memo_files, _memo_imports = compile_memo_components(
memos=tuple(ctx.auto_memo_components.values()),
)
memo_code = "\n".join(code for _, code in memo_files)
state_wiring = "useContext(StateContexts"
page_output = page_ctx.output_code
assert page_output is not None
assert state_wiring in memo_code
assert state_wiring not in page_output
assert body_marker in memo_code
assert body_marker not in page_output
def test_foreach_snapshot_memo_applies_component_styles() -> None:
"""Foreach memo bodies must render styled children after preview rendering.
Regression for reflex-dev/reflex#6512: the auto-memo wrapper previews a
Foreach render before the memo body is compiled. If that unstyled preview
render stays cached, default styles from components inside the Foreach
never reach the generated memo module.
"""
from reflex.compiler.compiler import compile_memo_components
def accordion() -> Component:
return rx.accordion.root(
rx.accordion.item(
header="Click me",
content=rx.text("Content here"),
),
type="single",
collapsible=True,
width="400px",
)
ctx, _page_ctx = _compile_single_page(
lambda: rx.vstack(
rx.foreach(SpecialFormMemoState.items, lambda _item: accordion()),
)
)
memo_files, _memo_imports = compile_memo_components(
memos=tuple(ctx.auto_memo_components.values()),
)
foreach_code = next(
code for path, code in memo_files if "/Foreach" in path or "\\Foreach" in path
)
assert '["width"] : "400px"' in foreach_code
assert '["borderRadius"] : "var(--radius-4)"' in foreach_code
assert '["justifyContent"] : "space-between"' in foreach_code
def test_foreach_parent_does_not_absorb_sibling_into_snapshot() -> None:
"""Foreach owns its own snapshot while the parent stays passthrough.
Regression for the foreach-parent memoization fix: a parent component
holding a Foreach used to be promoted to SNAPSHOT, absorbing any sibling
reactive content into the same wide memo body. The parent should now render
on the page side, with Foreach and any reactive sibling each getting their
own independent wrapper.
"""
ctx, _page_ctx = _compile_single_page(
lambda: rx.box(
Bare.create(SpecialFormMemoState.items.length()),
rx.foreach(
SpecialFormMemoState.items,
lambda item: rx.text(item),
),
)
)
wrapped_definitions = [
definition
for definition in ctx.auto_memo_components.values()
if isinstance(definition, MemoComponentDefinition)
]
wrapped_types = {type(definition.component) for definition in wrapped_definitions}
assert len(wrapped_definitions) == 2
assert Box not in wrapped_types
foreach_definition = next(
definition
for definition in wrapped_definitions
if isinstance(definition.component, Foreach)
)
assert (
get_memoization_strategy(foreach_definition.component)
is MemoizationStrategy.SNAPSHOT
)
bare_definition = next(
definition
for definition in wrapped_definitions
if isinstance(definition.component, Bare)
)
assert (
get_memoization_strategy(bare_definition.component)
is MemoizationStrategy.PASSTHROUGH
)
assert bare_definition is not foreach_definition
def test_common_memoization_snapshot_helper_classifies_snapshot_cases() -> None:
"""The shared memoization strategy classifies structural render forms."""
from reflex_components_core.core.cond import Cond
from reflex_components_core.core.foreach import Foreach
from reflex_components_core.core.match import Match
from reflex_components_core.el.elements.forms import Form, Input
foreach_parent = rx.box(
rx.foreach(
SpecialFormMemoState.items,
lambda item: rx.text(item),
)
)
cond_fragment = cast(
Component,
rx.cond(
SpecialFormMemoState.flag,
rx.text("yes"),
rx.text("no"),
),
)
match_fragment = cast(
Component,
rx.match(
SpecialFormMemoState.value,
("a", rx.text("A")),
rx.text("default"),
),
)
# A parent with a structural-memoization child (Foreach) is itself
# PASSTHROUGH โ the snapshotting is owned by the structural child, which
# captures its whole subtree.
assert get_memoization_strategy(foreach_parent) is MemoizationStrategy.PASSTHROUGH
foreach_child = foreach_parent.children[0]
assert isinstance(foreach_child, Foreach)
assert get_memoization_strategy(foreach_child) is MemoizationStrategy.SNAPSHOT
assert get_memoization_strategy(cond_fragment) is MemoizationStrategy.PASSTHROUGH
# Cond and Match now use passthrough so branch JSX renders on the page side
# and the memo body just selects via children[i] indexing.
assert isinstance(cond_fragment.children[0], Cond)
assert (
get_memoization_strategy(cond_fragment.children[0])
is MemoizationStrategy.PASSTHROUGH
)
assert isinstance(match_fragment.children[0], Match)
assert (
get_memoization_strategy(match_fragment.children[0])
is MemoizationStrategy.PASSTHROUGH
)
assert (
get_memoization_strategy(LeafComponent.create(Plain.create()))
is MemoizationStrategy.SNAPSHOT
)
form = Form.create(Input.create(name="username", id="username"))
assert get_memoization_strategy(form) is MemoizationStrategy.PASSTHROUGH
def test_generated_memo_component_is_not_itself_memoized() -> None:
"""The generated memo component instance itself is skipped by the heuristic."""
wrapper_factory, _definition = create_passthrough_component_memo(Fragment.create())
wrapper = wrapper_factory(Plain.create())
assert isinstance(wrapper, MemoComponent)
assert not _should_memoize(wrapper)
def test_auto_memo_wrapper_opts_out_of_being_memoized() -> None:
"""Generated wrappers carry ``NEVER`` so the pass can't wrap them again.
The wrapper is itself a ``MemoComponent``; without the opt-out, a wrapper
built around a stateful component would look eligible to the heuristic and
the pass would wrap wrappers forever.
"""
from reflex_base.event import EventChain
wrapper_factory, definition = create_passthrough_component_memo(
WithProp.create(label=STATE_VAR)
)
assert definition.auto_memo_wrapper
wrapper = wrapper_factory()
assert isinstance(wrapper, MemoComponent)
assert wrapper._memoization_mode.disposition is MemoizationDisposition.NEVER
assert not _should_memoize(wrapper)
# Even a signal the heuristic normally treats as eligible must not win.
wrapper.event_triggers["on_click"] = Var(_js_expr="test_event")._replace(
_var_type=EventChain,
merge_var_data=VarData(state="TestState"),
)
assert not _should_memoize(wrapper)
def test_user_memo_with_stateful_prop_is_auto_memoized() -> None:
"""An ``@rx.memo`` component bound to state gets its own memo wrapper.
Regression: ``MemoComponent`` used to opt out of auto-memoization
wholesale, so binding a state Var at the call site left the state
``useContext`` in the page module โ every state change then re-rendered
the whole page, including static siblings. The hooks must live in a
generated wrapper instead, which re-renders on state change and lets
React's ``memo`` skip the wrapped component unless a prop value changed.
"""
from reflex.compiler.compiler import compile_memo_components
@rx.memo
def stateful_card(label: rx.Var[str]) -> Component:
return WithProp.create(label=label)
ctx, page_ctx = _compile_single_page(
lambda: Fragment.create(
Plain.create(LiteralVar.create("static sibling")),
stateful_card(label=SpecialFormMemoState.value),
)
)
page_output = page_ctx.output_code
assert page_output is not None
assert "useContext(StateContexts" not in page_output
assert not any("useContext(StateContexts" in hook for hook in page_ctx.hooks)
(definition,) = ctx.auto_memo_components.values()
assert isinstance(definition, MemoComponentDefinition)
wrapped = definition.component
assert isinstance(wrapped, MemoComponent)
assert wrapped.tag is not None
assert wrapped.tag.startswith("StatefulCard")
# The page renders the wrapper; the wrapper renders the user's memo with
# the state-bound prop.
assert f"jsx({definition.export_name}," in page_output
memo_files, _memo_imports = compile_memo_components(
memos=tuple(ctx.auto_memo_components.values()),
)
wrapper_code = next(
code for path, code in memo_files if definition.export_name in path
)
assert "useContext(StateContexts" in wrapper_code
assert f"jsx({wrapped.tag}," in wrapper_code
def test_user_memo_with_static_props_is_not_auto_memoized() -> None:
"""A memo with no reactive props stays inline โ no wrapper is generated."""
@rx.memo
def static_card(label: rx.Var[str]) -> Component:
return WithProp.create(label=label)
ctx, page_ctx = _compile_single_page(
lambda: Fragment.create(static_card(label="static"))
)
assert not ctx.auto_memo_components
page_output = page_ctx.output_code
assert page_output is not None
assert f"jsx({static_card(label='static').tag}," in page_output
def test_user_memo_event_trigger_usecallback_leaves_page_scope() -> None:
"""A memo's event-handler prop is memoized inside the generated wrapper.
An inline arrow recreated on every page render defeats the ``memo`` the
user asked for; the wrapper hoists it into a ``useCallback`` living beside
the state hooks it depends on.
"""
from reflex.compiler.compiler import compile_memo_components
@rx.memo
def clickable(
on_click: rx.EventHandler[rx.event.no_args_event_spec],
) -> Component:
return Plain.create(on_click=on_click)
ctx, page_ctx = _compile_single_page(
lambda: Fragment.create(clickable(on_click=MemoTriggerState.ping))
)
assert not any("useCallback" in hook for hook in page_ctx.hooks)
(definition,) = ctx.auto_memo_components.values()
memo_files, _memo_imports = compile_memo_components(
memos=tuple(ctx.auto_memo_components.values()),
)
wrapper_code = next(
code for path, code in memo_files if definition.export_name in path
)
assert "useCallback" in wrapper_code
def test_user_memo_children_render_in_page_scope() -> None:
"""The wrapper passes children through instead of capturing them.
Children keep compiling in the page module (so their own reactive parts
get independent wrappers), and the memo body only holds the ``{children}``
hole plus the state-bound props.
"""
from reflex.compiler.compiler import compile_memo_components
@rx.memo
def slot_card(label: rx.Var[str], children: rx.Var[Component]) -> Component:
return WithProp.create(children, label=label)
ctx, page_ctx = _compile_single_page(
lambda: Fragment.create(
slot_card(
Plain.create(LiteralVar.create("static child")),
label=SpecialFormMemoState.value,
)
)
)
page_output = page_ctx.output_code
assert page_output is not None
assert "useContext(StateContexts" not in page_output
assert 'jsx(Plain,{},"static child")' in page_output
wrapper_definition = next(
definition
for definition in ctx.auto_memo_components.values()
if isinstance(definition, MemoComponentDefinition)
and isinstance(definition.component, MemoComponent)
)
memo_files, _memo_imports = compile_memo_components(
memos=tuple(ctx.auto_memo_components.values()),
)
wrapper_code = next(
code for path, code in memo_files if wrapper_definition.export_name in path
)
inner_tag = wrapper_definition.component.tag
assert f"jsx({inner_tag}," in wrapper_code
# The hole, not the authored child, is what the memo body renders.
assert ",children)" in wrapper_code
assert "static child" not in wrapper_code
def test_user_memo_inside_foreach_is_not_independently_memoized() -> None:
"""Foreach owns its snapshot, so a memo inside it renders in that body."""
from reflex.compiler.compiler import compile_memo_components
@rx.memo
def row(label: rx.Var[str]) -> Component:
return WithProp.create(label=label)
ctx, _page_ctx = _compile_single_page(
lambda: rx.box(
rx.foreach(SpecialFormMemoState.items, lambda item: row(label=item))
)
)
(definition,) = ctx.auto_memo_components.values()
assert isinstance(definition, MemoComponentDefinition)
assert isinstance(definition.component, Foreach)
memo_files, _memo_imports = compile_memo_components(
memos=tuple(ctx.auto_memo_components.values()),
)
memo_code = "\n".join(code for _, code in memo_files)
assert f"jsx({row(label='x').tag}," in memo_code
def test_passthrough_memo_skips_hole_for_childless_component() -> None:
"""Childless components own their JSX output, so the wrapper must not
inject a ``{children}`` hole.
Regression: components like ``CodeBlock`` set ``children`` on their own
rendered Tag via ``_render``. Substituting a ``Bare({children})`` hole
would emit ``jsx(Inner, {children: "..."}, hole)``, and at call time the
undefined hole arg overwrites ``props.children`` under Emotion's jsx
semantics โ causing every reactive ``rx.code_block`` to render an empty
``<code>`` element.
"""
component = ChildrenViaProp.create(code=STATE_VAR)
assert not component.children
_wrapper_factory, definition = create_passthrough_component_memo(component)
assert definition.passthrough_hole_child is None
def test_event_trigger_memoization_not_emit_usecallback_in_page_hooks() -> None:
"""Components with event triggers do not get useCallback wrappers at the page level."""
from reflex_base.event import EventChain
# Construct an event chain referencing state so _get_memoized_event_triggers
# emits a useCallback.
event_var = Var(_js_expr="test_event")._replace(
_var_type=EventChain,
merge_var_data=VarData(state="TestState"),
)
comp = Plain.create()
comp.event_triggers["on_click"] = event_var
_ctx, page_ctx = _compile_single_page(lambda: comp)
# Check that a useCallback hook line was added to the page hooks dict.
hook_lines = list(page_ctx.hooks.keys())
assert not any(
"useCallback" in hook_line and "on_click_" in hook_line
for hook_line in hook_lines
), f"Expected no on_click useCallback hook in {hook_lines!r}"
def test_generated_memo_component_renders_as_its_exported_tag() -> None:
"""The generated experimental memo component renders as its exported tag."""
wrapper_factory, definition = create_passthrough_component_memo(Fragment.create())
wrapper = wrapper_factory(Plain.create())
assert isinstance(wrapper, MemoComponent)
tag = definition.export_name
assert tag.startswith("Fragment_"), (
f"Expected the wrapped class qualname to be encoded in the tag prefix; "
f"got {tag!r}"
)
assert wrapper.tag == tag
assert wrapper.render()["name"] == tag
def test_auto_memo_display_name_is_the_wrapped_python_class() -> None:
"""Auto-memo wrappers are labelled with the class they wrap, not their tag.
``export_name`` carries a content hash so identically-rendering subtrees
collapse to one module; that name is unreadable in the React DevTools tree,
so the memo's ``displayName`` names the Python component instead.
"""
from reflex.compiler.compiler import compile_memo_components
ctx, _ = _compile_single_page(
lambda: Fragment.create(WithProp.create(label=STATE_VAR))
)
definitions = list(ctx.auto_memo_components.values())
assert [definition.display_name for definition in definitions] == ["WithProp"]
memo_code = "\n".join(
code for _, code in compile_memo_components(memos=tuple(definitions))[0]
)
assert f'{definitions[0].export_name}.displayName = "WithProp";' in memo_code
def test_passthrough_memo_definitions_are_not_shared_globally(monkeypatch) -> None:
"""Repeated tags across compiles rebuild their passthrough definitions.
Regression: sharing auto-memo definitions globally by tag leaks the first
app's captured component tree into later compiles, which can stale-bind
state event names across AppHarness apps.
"""
tag = "SharedMemoTag"
first_component = Plain.create(STATE_VAR)
second_component = Plain.create(STATE_VAR)
monkeypatch.setattr(
memoize_plugin,
"fix_event_triggers_for_memo",
lambda comp, page_context: comp,
)
def fake_create_passthrough_component_memo(
component: Component, source_module: str | None = None
):
definition = SimpleNamespace(
export_name=tag,
component=component,
source_module=source_module,
)
return (lambda definition=definition: definition), definition
monkeypatch.setattr(
memoize_plugin,
"create_passthrough_component_memo",
fake_create_passthrough_component_memo,
)
first_compile = SimpleNamespace(memoize_wrappers={}, auto_memo_components={})
second_compile = SimpleNamespace(memoize_wrappers={}, auto_memo_components={})
page_context = cast(PageContext, SimpleNamespace(source_module=None))
MemoizeStatefulPlugin._build_wrapper(
first_component,
page_context=page_context,
compile_context=first_compile,
)
MemoizeStatefulPlugin._build_wrapper(
second_component,
page_context=page_context,
compile_context=second_compile,
)
first_definition = first_compile.auto_memo_components[tag, None]
second_definition = second_compile.auto_memo_components[tag, None]
assert first_definition.component is first_component
assert second_definition.component is second_component
assert second_definition is not first_definition
def test_shared_subtree_across_pages_uses_same_tag() -> None:
"""The same memoizable subtree on multiple pages gets one shared tag."""
ctx = CompileContext(
pages=[
FakePage(route="/a", component=lambda: Plain.create(STATE_VAR)),
FakePage(route="/b", component=lambda: Plain.create(STATE_VAR)),
],
hooks=CompilerHooks(plugins=default_page_plugins()),
)
with ctx:
ctx.compile()
assert len(ctx.memoize_wrappers) == 1
tag = next(iter(ctx.memoize_wrappers))
assert list(ctx.auto_memo_components) == [(tag, None)]
for route in ("/a", "/b"):
output = ctx.compiled_pages[route].output_code or ""
assert f'import {{{tag}}} from "$/utils/components/{tag}"' in output
assert f"jsx({tag}," in output
def test_shared_subtree_in_distinct_source_modules_emits_per_module() -> None:
"""Identical subtrees in different user modules emit one memo per module.
Regression: the auto-memo registry was keyed by tag only, so when two
pages from different user modules produced the same memoizable subtree
(and therefore the same tag), the second registration overwrote the
first. Only the surviving definition's source module got a mirrored
memo file, leaving the other page importing a tag from a file that
never exported it.
"""
from reflex.compiler.compiler import compile_memo_components
ctx = CompileContext(
pages=[
FakePage(
route="/a",
component=lambda: Plain.create(STATE_VAR),
_source_module="memo_collision_test.module_a",
),
FakePage(
route="/b",
component=lambda: Plain.create(STATE_VAR),
_source_module="memo_collision_test.module_b",
),
],
hooks=CompilerHooks(plugins=default_page_plugins()),
)
with ctx:
ctx.compile()
assert len(ctx.memoize_wrappers) == 1
tag = next(iter(ctx.memoize_wrappers))
# Both source modules survive registration as distinct entries.
assert set(ctx.auto_memo_components) == {
(tag, "memo_collision_test.module_a"),
(tag, "memo_collision_test.module_b"),
}
# Each module emits and imports the tag under a per-module symbol so the
# two pages never collide on a shared JS identifier.
symbol_a = memo_paths.mirrored_symbol(tag, "memo_collision_test.module_a")
symbol_b = memo_paths.mirrored_symbol(tag, "memo_collision_test.module_b")
page_a_output = ctx.compiled_pages["/a"].output_code or ""
page_b_output = ctx.compiled_pages["/b"].output_code or ""
assert (
f'import {{{symbol_a}}} from "$/app_components/memo_collision_test/module_a"'
in page_a_output
)
assert (
f'import {{{symbol_b}}} from "$/app_components/memo_collision_test/module_b"'
in page_b_output
)
output_files, _ = compile_memo_components(
memos=tuple(ctx.auto_memo_components.values()),
)
emitted = {Path(path).as_posix(): code for path, code in output_files}
def find_emitted(suffix: str) -> str | None:
return next(
(code for path, code in emitted.items() if path.endswith(suffix)), None
)
matched_a = find_emitted("memo_collision_test/module_a.jsx")
matched_b = find_emitted("memo_collision_test/module_b.jsx")
assert matched_a is not None, f"missing module_a memo file in {sorted(emitted)}"
assert matched_b is not None, f"missing module_b memo file in {sorted(emitted)}"
assert f"const {symbol_a} = memo" in matched_a
assert f"const {symbol_b} = memo" in matched_b
def test_shared_parent_instance_across_pages_preserves_original() -> None:
"""A parent instance reused across pages must not have its children rebound.
Regression: the compile walker replaces memoizable descendants with memo
wrappers and writes the new children list onto their parent. If the parent
is the same Python object on two pages (e.g. a module-scope layout), page
A's compile would mutate page B's starting tree, producing a ``ReferenceError``
for the memo tag on the second page.
"""
shared_parent = Fragment.create(WithProp.create(label=STATE_VAR))
original_children = list(shared_parent.children)
original_child = shared_parent.children[0]
ctx = CompileContext(
pages=[
FakePage(route="/a", component=lambda: shared_parent),
FakePage(route="/b", component=lambda: shared_parent),
],
hooks=CompilerHooks(plugins=default_page_plugins()),
)
with ctx:
ctx.compile()
assert shared_parent.children == original_children, (
f"shared parent's children mutated: {shared_parent.children!r}"
)
assert shared_parent.children[0] is original_child, (
"shared parent's child reference replaced by a memo wrapper"
)