dara-core 1.20.2__py3-none-any.whl → 1.21.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dara/core/main.py CHANGED
@@ -16,24 +16,31 @@ limitations under the License.
16
16
  """
17
17
 
18
18
  import asyncio
19
+ import json
19
20
  import os
20
21
  import sys
22
+ import traceback
23
+ from collections.abc import Mapping
21
24
  from concurrent.futures import ThreadPoolExecutor
22
25
  from contextlib import asynccontextmanager
23
26
  from importlib.util import find_spec
24
27
  from inspect import iscoroutine
25
28
  from pathlib import Path
26
- from typing import Optional
29
+ from typing import Annotated, Any, Dict, List, Optional
30
+ from urllib.parse import unquote
27
31
 
28
32
  from anyio import create_task_group
29
- from fastapi import FastAPI, HTTPException, Request
30
- from fastapi.encoders import ENCODERS_BY_TYPE
31
- from fastapi.responses import HTMLResponse
33
+ from fastapi import Body, Depends, FastAPI, HTTPException, Request
34
+ from fastapi import Path as PathParam
35
+ from fastapi.encoders import ENCODERS_BY_TYPE, jsonable_encoder
32
36
  from fastapi.staticfiles import StaticFiles
33
37
  from prometheus_client import start_http_server
38
+ from pydantic import BaseModel, Field
39
+ from starlette.responses import FileResponse
34
40
  from starlette.templating import Jinja2Templates, _TemplateResponse
35
41
 
36
42
  from dara.core.auth import auth_router
43
+ from dara.core.auth.routes import verify_session
37
44
  from dara.core.configuration import Configuration, ConfigurationBuilder
38
45
  from dara.core.defaults import (
39
46
  blank_template,
@@ -44,18 +51,21 @@ from dara.core.defaults import (
44
51
  from dara.core.internal.cache_store import CacheStore
45
52
  from dara.core.internal.cgroup import get_cpu_count, set_memory_limit
46
53
  from dara.core.internal.custom_response import CustomResponse
47
- from dara.core.internal.devtools import send_error_for_session
54
+ from dara.core.internal.devtools import print_stacktrace, send_error_for_session
48
55
  from dara.core.internal.encoder_registry import encoder_registry
56
+ from dara.core.internal.execute_action import CURRENT_ACTION_ID, execute_action_sync
57
+ from dara.core.internal.normalization import NormalizedPayload, denormalize, normalize
49
58
  from dara.core.internal.pool import TaskPool
50
59
  from dara.core.internal.registries import (
51
60
  action_def_registry,
61
+ action_registry,
52
62
  auth_registry,
53
63
  component_registry,
54
64
  config_registry,
55
65
  custom_ws_handlers_registry,
56
66
  latest_value_registry,
57
67
  sessions_registry,
58
- template_registry,
68
+ static_kwargs_registry,
59
69
  utils_registry,
60
70
  websocket_registry,
61
71
  )
@@ -64,7 +74,7 @@ from dara.core.internal.routing import create_router, error_decorator
64
74
  from dara.core.internal.settings import get_settings
65
75
  from dara.core.internal.tasks import TaskManager
66
76
  from dara.core.internal.utils import enforce_sso, import_config
67
- from dara.core.internal.websocket import WebsocketManager
77
+ from dara.core.internal.websocket import WS_CHANNEL, WebsocketManager
68
78
  from dara.core.js_tooling.js_utils import (
69
79
  BuildCache,
70
80
  BuildMode,
@@ -72,6 +82,16 @@ from dara.core.js_tooling.js_utils import (
72
82
  rebuild_js,
73
83
  )
74
84
  from dara.core.logging import LoggingMiddleware, dev_logger, eng_logger, http_logger
85
+ from dara.core.router import convert_template_to_router
86
+
87
+
88
+ class CacheStaticFiles(StaticFiles):
89
+ async def get_response(self, path, scope):
90
+ response = await super().get_response(path, scope)
91
+ # add 1 year cache for static assets
92
+ if isinstance(response, FileResponse) and path.endswith('.css') or path.endswith('.umd.js'):
93
+ response.headers['Cache-Control'] = 'public, max-age=31536000'
94
+ return response
75
95
 
76
96
 
77
97
  def _start_application(config: Configuration):
@@ -94,18 +114,9 @@ def _start_application(config: Configuration):
94
114
  import fastapi_vite_dara
95
115
  import fastapi_vite_dara.config
96
116
 
97
- if len(config.pages) > 0:
98
- BASE_DIR = Path(__file__).parent
99
- jinja_templates = Jinja2Templates(directory=str(Path(BASE_DIR, 'jinja')))
100
- jinja_templates.env.globals['vite_hmr_client'] = fastapi_vite_dara.vite_hmr_client
101
- jinja_templates.env.globals['vite_asset'] = fastapi_vite_dara.vite_asset
102
- jinja_templates.env.globals['static_url'] = fastapi_vite_dara.config.settings.static_url
103
- jinja_templates.env.globals['base_url'] = os.getenv('DARA_BASE_URL', '')
104
- jinja_templates.env.globals['entry'] = '_entry.tsx'
105
-
106
- # If --enable-hmr or --reload enabled, set live reload to true
107
- if os.environ.get('DARA_HMR_MODE') == 'TRUE' or os.environ.get('DARA_LIVE_RELOAD') == 'TRUE':
108
- config.live_reload = True
117
+ # If --enable-hmr or --reload enabled, set live reload to true
118
+ if os.environ.get('DARA_HMR_MODE') == 'TRUE' or os.environ.get('DARA_LIVE_RELOAD') == 'TRUE':
119
+ config.live_reload = True
109
120
 
110
121
  # Configure the default executor for threads run via the async loop
111
122
  loop = asyncio.get_event_loop()
@@ -259,26 +270,6 @@ def _start_application(config: Configuration):
259
270
  for key, value in encoder_registry.items():
260
271
  ENCODERS_BY_TYPE[key] = value['serialize']
261
272
 
262
- # Generate a new build_cache
263
- try:
264
- build_cache = BuildCache.from_config(config)
265
- build_diff = build_cache.get_diff()
266
-
267
- # Only build if there's pages to build, otherwise assume Dara is only used for API
268
- if len(config.pages) > 0:
269
- dev_logger.debug(
270
- 'Building JS...',
271
- extra={
272
- 'New build cache': build_cache.model_dump(),
273
- 'Difference from last cache': build_diff.model_dump(),
274
- },
275
- )
276
- rebuild_js(build_cache, build_diff)
277
-
278
- except Exception as e:
279
- dev_logger.error('Error building JS', error=e)
280
- sys.exit(1)
281
-
282
273
  # Loop over registered components and add to the registry
283
274
  eng_logger.info(f'Registering components [{", ".join([c.name for c in config.components])}]')
284
275
  for component in config.components:
@@ -292,37 +283,64 @@ def _start_application(config: Configuration):
292
283
  dev_logger.info(f'Using {config.auth_config.__class__.__name__} auth configuration')
293
284
  auth_registry.register('auth_config', config.auth_config)
294
285
 
286
+ # Handle pages/router compatibility
287
+ if len(config.pages) > 0:
288
+ if len(config.router.children) > 0:
289
+ raise ValueError(
290
+ 'ConfigurationBuilder.add_page is not compatible with the ConfigurationBuilder.router, `add_page` is supported for backwards compatibility but the recommended API going forward is using `config.router` directly.'
291
+ )
292
+
293
+ try:
294
+ eng_logger.info('Registering template')
295
+
296
+ # Add the default templates
297
+ if config.template == 'default':
298
+ eng_logger.info('Registering template "default"')
299
+ config.router = convert_template_to_router(default_template(config))
300
+ elif config.template == 'blank':
301
+ eng_logger.info('Registering template "blank"')
302
+ config.router = convert_template_to_router(blank_template(config))
303
+ elif config.template == 'top':
304
+ eng_logger.info('Registering template "top"')
305
+ config.router = convert_template_to_router(top_template(config))
306
+ elif config.template == 'top-menu':
307
+ eng_logger.info('Registering template "top-menu"')
308
+ config.router = convert_template_to_router(top_menu_template(config))
309
+ else:
310
+ # Loop over user defined templates and add to the registry
311
+ for name, renderer in config.template_renderers.items():
312
+ if name == config.template:
313
+ eng_logger.info(f'Registering custom template "{name}"')
314
+ config.router = convert_template_to_router(renderer(config))
315
+ break
316
+ else:
317
+ raise ValueError(f'Unknown template renderer: {config.template}')
318
+ except Exception as e:
319
+ traceback.print_exc()
320
+ dev_logger.error(
321
+ 'Something went wrong when building application template, there is most likely an issue in the application logic',
322
+ e,
323
+ )
324
+ sys.exit(1)
325
+
326
+ # Generate a new build_cache
295
327
  try:
296
- eng_logger.info('Registering template')
297
-
298
- # Add the default templates
299
- if config.template == 'default':
300
- eng_logger.info('Registering template "default"')
301
- template_registry.register('default', default_template(config))
302
- elif config.template == 'blank':
303
- eng_logger.info('Registering template "blank"')
304
- template_registry.register('blank', blank_template(config))
305
- elif config.template == 'top':
306
- eng_logger.info('Registering template "top"')
307
- template_registry.register('top', top_template(config))
308
- elif config.template == 'top-menu':
309
- eng_logger.info('Registering template "top-menu"')
310
- template_registry.register('top-menu', top_menu_template(config))
311
- else:
312
- # Loop over user defined templates and add to the registry
313
- for name, renderer in config.template_renderers.items():
314
- if name == config.template:
315
- eng_logger.info(f'Registering custom template "{name}"')
316
- template_registry.register(name, renderer(config))
328
+ build_cache = BuildCache.from_config(config)
329
+ build_diff = build_cache.get_diff()
317
330
 
331
+ # Only build if there's pages to build, otherwise assume Dara is only used for API
332
+ if len(config.router.children) > 0:
333
+ dev_logger.debug(
334
+ 'Building JS...',
335
+ extra={
336
+ 'New build cache': build_cache.model_dump(),
337
+ 'Difference from last cache': build_diff.model_dump(),
338
+ },
339
+ )
340
+ rebuild_js(build_cache, build_diff)
318
341
  except Exception as e:
319
- import traceback
320
-
321
342
  traceback.print_exc()
322
- dev_logger.error(
323
- 'Something went wrong when building application template, there is most likely an issue in the application logic',
324
- e,
325
- )
343
+ dev_logger.error('Error building JS', error=e)
326
344
  sys.exit(1)
327
345
 
328
346
  # Root routes
@@ -352,42 +370,147 @@ def _start_application(config: Configuration):
352
370
  start_pprof_server(port=profiling_port)
353
371
 
354
372
  # Serve statics, only if we have any pages defined
355
- if len(config.pages) > 0:
356
- app.mount('/static', StaticFiles(directory=config.static_files_dir), name='static')
373
+ if len(config.router.children) > 0:
374
+ app.mount('/static', CacheStaticFiles(directory=config.static_files_dir), name='static')
357
375
 
358
376
  # Mount Routers
359
377
  app.include_router(auth_router, prefix='/api/auth')
360
378
  app.include_router(core_api_router, prefix='/api/core')
361
379
 
362
- @app.get('/api/{rest_of_path:path}')
363
- async def not_found():
364
- raise HTTPException(status_code=404, detail='API endpoint not found')
380
+ if len(config.router.children) > 0:
381
+ BASE_DIR = Path(__file__).parent
382
+ jinja_templates = Jinja2Templates(directory=str(Path(BASE_DIR, 'jinja')))
383
+ jinja_templates.env.globals['vite_hmr_client'] = fastapi_vite_dara.vite_hmr_client
384
+ jinja_templates.env.globals['vite_asset'] = fastapi_vite_dara.vite_asset
385
+ jinja_templates.env.globals['static_url'] = fastapi_vite_dara.config.settings.static_url
386
+ jinja_templates.env.globals['base_url'] = os.getenv('DARA_BASE_URL', '')
387
+ jinja_templates.env.globals['entry'] = '_entry.tsx'
388
+
389
+ # Compile the router, executing all page functions etc
390
+ try:
391
+ config.router.compile()
392
+ except Exception as e:
393
+ traceback.print_exc()
394
+ dev_logger.error('Error compiling router', error=e)
395
+ sys.exit(1)
396
+
397
+ dev_logger.info('Registering pages:')
398
+ # TODO: convert this to use the logger?
399
+ config.router.print_route_tree()
400
+ route_map = config.router.to_route_map()
401
+
402
+ class ActionPayload(BaseModel):
403
+ uid: str
404
+ definition_uid: str
405
+ values: NormalizedPayload[Mapping[str, Any]]
406
+
407
+ class RouteDataRequestBody(BaseModel):
408
+ action_payloads: List[ActionPayload] = Field(default_factory=list)
409
+ ws_channel: Optional[str] = None
410
+ params: Dict[str, str] = Field(default_factory=dict)
411
+
412
+ @app.post('/api/core/route/{route_id}', dependencies=[Depends(verify_session)])
413
+ async def get_route_data(route_id: Annotated[str, PathParam()], body: Annotated[RouteDataRequestBody, Body()]):
414
+ # unquote route_id since it can be url-encoded
415
+ route_id = unquote(route_id)
416
+
417
+ # TODO: This will accept DV inputs etc and stream those back
418
+ route_data = route_map.get(route_id)
419
+
420
+ if route_data is None:
421
+ raise HTTPException(status_code=404, detail=f'Route {route_id} not found')
422
+
423
+ action_results: Dict[str, Any] = {}
424
+
425
+ if len(body.action_payloads) > 0:
426
+ store: CacheStore = utils_registry.get('Store')
427
+ task_mgr: TaskManager = utils_registry.get('TaskManager')
428
+ registry_mgr: RegistryLookup = utils_registry.get('RegistryLookup')
429
+
430
+ # Ws channel can be null for top-level layouts rendered above the ws client
431
+ if body.ws_channel is not None:
432
+ WS_CHANNEL.set(body.ws_channel)
433
+
434
+ # Run actions in order to guarantee execution order
435
+ for action_payload in body.action_payloads:
436
+ action_def = await registry_mgr.get(action_registry, action_payload.definition_uid)
437
+ static_kwargs = await registry_mgr.get(static_kwargs_registry, action_payload.uid)
438
+
439
+ CURRENT_ACTION_ID.set(action_payload.uid)
440
+ values = denormalize(action_payload.values.data, action_payload.values.lookup)
441
+ try:
442
+ action_results[action_payload.uid] = await execute_action_sync(
443
+ action_def,
444
+ inp={'params': body.params, 'route': route_data.definition},
445
+ values=values,
446
+ static_kwargs=static_kwargs,
447
+ store=store,
448
+ task_mgr=task_mgr,
449
+ )
450
+ except BaseException as e:
451
+ assert route_data.definition is not None
452
+ route_path = route_data.definition.full_path
453
+ action_name = str(action_def.resolver)
454
+ raise HTTPException(
455
+ status_code=500,
456
+ detail={
457
+ 'error': str(e),
458
+ 'stacktrace': print_stacktrace(e),
459
+ 'path': route_path,
460
+ 'action_name': action_name,
461
+ },
462
+ ) from e
463
+
464
+ normalized_template, lookup = normalize(jsonable_encoder(route_data.content))
465
+ return {'template': {'data': normalized_template, 'lookup': lookup}, 'action_results': action_results}
466
+
467
+ # Catch-all, must add after adding the last api route
468
+ @app.get('/api/{rest_of_path:path}')
469
+ async def not_found():
470
+ raise HTTPException(status_code=404, detail='API endpoint not found')
471
+
472
+ # Prepare static template data
473
+ template_data = {
474
+ 'auth_components': config.auth_config.component_config.model_dump(),
475
+ 'actions': action_def_registry.get_all().items(),
476
+ 'components': {k: comp.model_dump(exclude={'func'}) for k, comp in component_registry.get_all().items()},
477
+ 'application_name': get_settings().project_name,
478
+ 'enable_devtools': config.enable_devtools,
479
+ 'live_reload': config.live_reload,
480
+ # TODO: this will become some backendstore-variable instead, prepopulated in here
481
+ 'theme': config.theme,
482
+ 'title': config.title,
483
+ 'context_components': config.context_components,
484
+ # For backwards compatibility
485
+ 'powered_by_causalens': config.powered_by_causalens,
486
+ 'router': config.router,
487
+ 'build_mode': build_cache.build_config.mode,
488
+ 'build_dev': build_cache.build_config.dev,
489
+ }
490
+ json_template_data = json.dumps(jsonable_encoder(template_data))
365
491
 
366
- if len(config.pages) > 0:
367
- dev_logger.info(f'Registering pages: [{", ".join(list(config.pages.keys()))}]')
368
492
  # For any unmatched route then serve the app to the user if we have any pages to serve
369
493
  # (Required for the chosen routing system in the UI)
370
494
 
495
+ context = {
496
+ 'dara_data': json_template_data,
497
+ }
498
+ template_name = 'index.html'
499
+
371
500
  # Auto-js mode - serve the built template with UMDs
372
501
  if build_cache.build_config.mode == BuildMode.AUTO_JS:
373
- # Load template
374
- template_path = os.path.join(Path(BASE_DIR, 'jinja'), 'index_autojs.html') # type: ignore
375
- with open(template_path, encoding='utf-8') as fp:
376
- template = fp.read()
377
-
378
- # Generate tags for the template
379
- template = build_autojs_template(template, build_cache, config)
502
+ template_name = 'index_autojs.html'
503
+ context.update(build_autojs_template(build_cache, config))
380
504
 
381
- @app.get('/{full_path:path}', include_in_schema=False, response_class=HTMLResponse)
382
- async def serve_app(request: Request): # pyright: ignore[reportRedeclaration]
383
- return HTMLResponse(template)
505
+ @app.get('/{full_path:path}', include_in_schema=False, response_class=_TemplateResponse)
506
+ async def serve_app(request: Request):
507
+ return jinja_templates.TemplateResponse(request, template_name, context=context)
384
508
 
385
- else:
386
- # Otherwise serve the Vite template
387
-
388
- @app.get('/{full_path:path}', include_in_schema=False, response_class=_TemplateResponse)
389
- async def serve_app(request: Request): # pyright: ignore[reportRedeclaration]
390
- return jinja_templates.TemplateResponse(request, 'index.html') # type: ignore
509
+ else:
510
+ # Catch-all, must be at the very end
511
+ @app.get('/api/{rest_of_path:path}')
512
+ async def not_found():
513
+ raise HTTPException(status_code=404, detail='API endpoint not found')
391
514
 
392
515
  return app
393
516
 
@@ -0,0 +1,4 @@
1
+ # ruff: noqa: F403
2
+ from .compat import *
3
+ from .components import *
4
+ from .router import *
@@ -0,0 +1,77 @@
1
+ from typing import List
2
+
3
+ from dara.core.definitions import ComponentInstance
4
+
5
+ from .components import Outlet
6
+ from .router import Router
7
+
8
+
9
+ def convert_template_to_router(template):
10
+ """
11
+ Convert old template system to new Router structure.
12
+
13
+ The conversion maps:
14
+ - Template.layout becomes the content of a root LayoutRoute, but with transformations:
15
+ - RouterContent components are replaced with Outlet components
16
+ - RouterContent.routes are extracted and become PageRoute/IndexRoute children
17
+ - Routes with route='/' become IndexRoute, others become PageRoute
18
+ - Route paths are normalized (leading slashes removed)
19
+
20
+ :param template: Template object with layout component containing RouterContent
21
+ :return: Router instance with converted structure
22
+ """
23
+ from dara.core.definitions import TemplateRouterContent
24
+ from dara.core.visual.components.router_content import RouterContent
25
+
26
+ router = Router()
27
+ extracted_routes: List[TemplateRouterContent] = []
28
+
29
+ # Transform the layout: replace RouterContent with Outlet and extract routes
30
+ def transform_component(component):
31
+ """Recursively transform components, replacing RouterContent with Outlet"""
32
+ # For other components, recursively transform their children/content
33
+ if isinstance(component, ComponentInstance):
34
+ if isinstance(component, RouterContent):
35
+ extracted_routes.extend(component.routes)
36
+ return Outlet()
37
+
38
+ for attr in component.model_fields_set:
39
+ value = getattr(component, attr, None)
40
+ if isinstance(value, ComponentInstance):
41
+ setattr(component, attr, transform_component(value))
42
+ elif isinstance(value, list) and len(value) > 0 and isinstance(value[0], ComponentInstance):
43
+ setattr(component, attr, [transform_component(item) for item in value])
44
+
45
+ return component
46
+
47
+ # Create root layout route using transformed template layout
48
+ root_layout = router.add_layout(content=transform_component(template.layout))
49
+
50
+ # Convert extracted routes to appropriate route types
51
+ for route_content in extracted_routes:
52
+ # Normalize route path: remove leading slash and handle root
53
+ route_path = route_content.route
54
+ if route_path.startswith('/'):
55
+ route_path = route_path[1:]
56
+
57
+ # NOTE: here it's safe to use the name as the id, as in the old api the name was unique
58
+ # but use 'index' for empty strings
59
+
60
+ # Root path becomes index route
61
+ if route_path in {'', '/'}:
62
+ root_layout.add_index(
63
+ content=route_content.content,
64
+ name=route_content.name,
65
+ id=route_content.name or 'index',
66
+ on_load=route_content.on_load,
67
+ )
68
+ else:
69
+ root_layout.add_page(
70
+ path=route_path,
71
+ content=route_content.content,
72
+ name=route_content.name,
73
+ id=route_content.name or 'index',
74
+ on_load=route_content.on_load,
75
+ )
76
+
77
+ return router
@@ -0,0 +1,109 @@
1
+ from typing import Annotated, Any, Literal, Optional, Union
2
+
3
+ from pydantic import BaseModel, BeforeValidator
4
+
5
+ from dara.core.definitions import ComponentInstance, JsComponentDef, StyledComponentInstance, transform_raw_css
6
+
7
+
8
+ class RouterPath(BaseModel):
9
+ path: str
10
+ """
11
+ A URL pathname, beginning with '/'.
12
+ """
13
+
14
+ search: Optional[str] = None
15
+ """
16
+ A URL search string, beginning with '?'.
17
+ """
18
+
19
+ hash: Optional[str] = None
20
+ """
21
+ A URL hash string, beginning with '#'.
22
+ """
23
+
24
+
25
+ OutletDef = JsComponentDef(name='Outlet', js_module='@darajs/core', py_module='dara.core')
26
+
27
+
28
+ class Outlet(ComponentInstance):
29
+ """
30
+ Outlet component is a placeholder for the content of the current route.
31
+ """
32
+
33
+
34
+ LinkDef = JsComponentDef(name='Link', js_module='@darajs/core', py_module='dara.core')
35
+
36
+
37
+ class Link(StyledComponentInstance):
38
+ """
39
+ Link component is a wrapper around the NavLink component that displays a link to the specified route.
40
+ """
41
+
42
+ case_sensitive: bool = False
43
+
44
+ end: bool = True
45
+ """
46
+ Changes the matching logic for the 'active' state to only match the end of the 'to' prop.
47
+ If the URL is longer, it will not be considered active. Defaults to True.
48
+
49
+ For example, NavLink(to='/tasks') while on '/tasks/123' will:
50
+ - with `end=False`, be considered active because of the partial match of the `/tasks` part
51
+ - with `end=True`, be considered inactive because of the missing '123' part
52
+ """
53
+
54
+ # TODO: not implemented yet
55
+ # prefetch: Literal['none', 'intent', 'render', 'viewport'] = 'none'
56
+ # """
57
+ # Defines the data and module prefetching behavior for the link.
58
+ # - none — default, no prefetching
59
+ # - intent — prefetches when the user hovers or focuses the link
60
+ # - render — prefetches when the link renders
61
+ # - viewport — prefetches when the link is in the viewport, very useful for mobile
62
+ # """
63
+
64
+ relative: Literal['route', 'path'] = 'route'
65
+ """
66
+ Defines the relative path behavior for the link.
67
+
68
+ ```python
69
+ Link(to='..') # default, relative='route'
70
+ Link(to='..', relative='path')
71
+ ```
72
+
73
+ Consider a route hierarchy where a parent route pattern is "blog" and a child route pattern is "blog/:slug/edit".
74
+ - route — default, resolves the link relative to the route pattern. In the example above, a relative link of "..." will remove both :slug/edit segments back to "/blog".
75
+ - path — relative to the path so "..." will only remove one URL segment up to "/blog/:slug"
76
+ Note that index routes and layout routes do not have paths so they are not included in the relative path calculation.
77
+ """
78
+
79
+ replace: bool = False
80
+ """
81
+ Replaces the current entry in the history stack instead of pushing a new one.
82
+
83
+ ```
84
+ # with a history stack like this
85
+ A -> B
86
+
87
+ # normal link click pushes a new entry
88
+ A -> B -> C
89
+
90
+ # but with `replace`, B is replaced by C
91
+ A -> C
92
+ ```
93
+ """
94
+
95
+ to: Union[str, RouterPath]
96
+ """
97
+ Can be a string or RouterPath object
98
+ """
99
+
100
+ active_css: Annotated[Optional[Any], BeforeValidator(transform_raw_css)] = None
101
+ inactive_css: Annotated[Optional[Any], BeforeValidator(transform_raw_css)] = None
102
+
103
+ # TODO: add scroll restoration if it works?
104
+
105
+ def __init__(self, *children: ComponentInstance, **kwargs):
106
+ components = list(children)
107
+ if 'children' not in kwargs:
108
+ kwargs['children'] = components
109
+ super().__init__(**kwargs)