diff --git a/docs/source/about/changelog.rst b/docs/source/about/changelog.rst index 30d595b94..3df32b84f 100644 --- a/docs/source/about/changelog.rst +++ b/docs/source/about/changelog.rst @@ -23,7 +23,8 @@ more info, see the :ref:`Contributor Guide `. Unreleased ---------- -Nothing yet... +**Added** +- :issue:`1075` - Add support to localStorage and sessionStorage access v1.0.2 diff --git a/src/js/packages/@reactpy/client/src/components.tsx b/src/js/packages/@reactpy/client/src/components.tsx index 728c4cec7..8fb734175 100644 --- a/src/js/packages/@reactpy/client/src/components.tsx +++ b/src/js/packages/@reactpy/client/src/components.tsx @@ -40,6 +40,30 @@ export function Layout(props: { client: ReactPyClient }): JSX.Element { [currentModel, props.client], ); + useEffect( + () => + props.client.onMessage("sync-local-storage", ({ type, storage}) => { + for (let itemKey in storage) { + window.localStorage.setItem( + itemKey, + storage[itemKey] + ) + } + }) + ) + + useEffect( + () => + props.client.onMessage("sync-session-storage", ({ type, storage}) => { + for (let itemKey in storage) { + window.sessionStorage.setItem( + itemKey, + storage[itemKey] + ) + } + }) + ) + return ( diff --git a/src/js/packages/@reactpy/client/src/messages.ts b/src/js/packages/@reactpy/client/src/messages.ts index 34001dcb0..b8073391c 100644 --- a/src/js/packages/@reactpy/client/src/messages.ts +++ b/src/js/packages/@reactpy/client/src/messages.ts @@ -12,6 +12,16 @@ export type LayoutEventMessage = { data: any; }; -export type IncomingMessage = LayoutUpdateMessage; -export type OutgoingMessage = LayoutEventMessage; +export type LocalStorageUpdateMessage = { + type: "sync-local-storage", + storage: any; +} + +export type SessionStorageUpdateMessage = { + type: "sync-session-storage", + storage: any; +} + +export type IncomingMessage = LayoutUpdateMessage | LocalStorageUpdateMessage | SessionStorageUpdateMessage; +export type OutgoingMessage = LayoutEventMessage | LocalStorageUpdateMessage | SessionStorageUpdateMessage; export type Message = IncomingMessage | OutgoingMessage; diff --git a/src/js/packages/@reactpy/client/src/reactpy-client.ts b/src/js/packages/@reactpy/client/src/reactpy-client.ts index 6f37b55a1..1c05721a5 100644 --- a/src/js/packages/@reactpy/client/src/reactpy-client.ts +++ b/src/js/packages/@reactpy/client/src/reactpy-client.ts @@ -204,6 +204,39 @@ function createReconnectingWebSocket( socket.current.onopen = () => { everConnected = true; logger.log("client connected"); + + let _localStorage : any = {} + for (let i = 0; i <= window.localStorage.length; i++) { + let storage_key = window.localStorage.key(i) + if (storage_key){ + _localStorage[storage_key] = window.localStorage.getItem(storage_key) + } + } + socket.current?.send( + JSON.stringify( + { + "type": "sync-local-storage", + "storage": _localStorage + } + ) + ) + + let _sessionStorage : any = {} + for (let i = 0; i <= window.sessionStorage.length; i++) { + let storage_key = window.sessionStorage.key(i) + if (storage_key){ + _sessionStorage[storage_key] = window.sessionStorage.getItem(storage_key) + } + } + socket.current?.send( + JSON.stringify( + { + "type": "sync-local-storage", + "storage": _sessionStorage + } + ) + ) + interval = startInterval; retries = 0; if (props.onOpen) { diff --git a/src/py/reactpy/reactpy/backend/hooks.py b/src/py/reactpy/reactpy/backend/hooks.py index 19ad114ed..7578ae982 100644 --- a/src/py/reactpy/reactpy/backend/hooks.py +++ b/src/py/reactpy/reactpy/backend/hooks.py @@ -5,6 +5,7 @@ from reactpy.backend.types import Connection, Location from reactpy.core.hooks import Context, create_context, use_context +from reactpy.core.types import LocalStorage, SessionStorage # backend implementations should establish this context at the root of an app ConnectionContext: Context[Connection[Any] | None] = create_context(None) @@ -27,3 +28,11 @@ def use_scope() -> MutableMapping[str, Any]: def use_location() -> Location: """Get the current :class:`~reactpy.backend.types.Connection`'s location.""" return use_connection().location + +def use_local_storage() -> LocalStorage: + """Get the localStorage object for the connection""" + return use_connection().local_storage + +def use_session_storage() -> SessionStorage: + """Get the sessionStorage object for the connection""" + return use_connection().session_storage \ No newline at end of file diff --git a/src/py/reactpy/reactpy/backend/starlette.py b/src/py/reactpy/reactpy/backend/starlette.py index 3a9695b33..4044c89b0 100644 --- a/src/py/reactpy/reactpy/backend/starlette.py +++ b/src/py/reactpy/reactpy/backend/starlette.py @@ -29,7 +29,7 @@ from reactpy.config import REACTPY_WEB_MODULES_DIR from reactpy.core.layout import Layout from reactpy.core.serve import RecvCoroutine, SendCoroutine, serve_layout -from reactpy.core.types import RootComponentConstructor +from reactpy.core.types import RootComponentConstructor, LocalStorage, SessionStorage logger = logging.getLogger(__name__) @@ -140,6 +140,8 @@ async def model_stream(socket: WebSocket) -> None: pathname = "/" + socket.scope["path_params"].get("path", "") pathname = pathname[len(options.url_prefix) :] or "/" search = socket.scope["query_string"].decode() + local_storage_obj = LocalStorage(sock=socket) + session_storage_obj = SessionStorage(sock=socket) try: await serve_layout( @@ -149,10 +151,14 @@ async def model_stream(socket: WebSocket) -> None: value=Connection( scope=socket.scope, location=Location(pathname, f"?{search}" if search else ""), + local_storage=local_storage_obj, + session_storage=session_storage_obj, carrier=socket, ), ) ), + local_storage_obj, + session_storage_obj, send, recv, ) diff --git a/src/py/reactpy/reactpy/backend/types.py b/src/py/reactpy/reactpy/backend/types.py index fbc4addc0..5a8831db5 100644 --- a/src/py/reactpy/reactpy/backend/types.py +++ b/src/py/reactpy/reactpy/backend/types.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from typing import Any, Callable, Generic, Protocol, TypeVar, runtime_checkable -from reactpy.core.types import RootComponentConstructor +from reactpy.core.types import RootComponentConstructor, LocalStorage, SessionStorage _App = TypeVar("_App") @@ -51,6 +51,12 @@ class Connection(Generic[_Carrier]): location: Location """The current location (URL)""" + local_storage: LocalStorage + """An object to obtain client localStorage""" + + session_storage: SessionStorage + """An object to obtain client sessionStorage""" + carrier: _Carrier """How the connection is mediated. For example, a request or websocket. diff --git a/src/py/reactpy/reactpy/core/serve.py b/src/py/reactpy/reactpy/core/serve.py index 3a530e854..c2f2bc6aa 100644 --- a/src/py/reactpy/reactpy/core/serve.py +++ b/src/py/reactpy/reactpy/core/serve.py @@ -8,8 +8,16 @@ from anyio.abc import TaskGroup from reactpy.config import REACTPY_DEBUG_MODE -from reactpy.core.types import LayoutEventMessage, LayoutType, LayoutUpdateMessage - +from reactpy.core.types import ( + LocalStorageEventMessage, + SessionStorageEventMessage, + LocalStorage, + SessionStorage, + LayoutEventMessage, + LayoutType, + LayoutUpdateMessage +) +from reactpy.core.layout import Layout logger = getLogger(__name__) @@ -33,6 +41,8 @@ class Stop(BaseException): async def serve_layout( layout: LayoutType[LayoutUpdateMessage, LayoutEventMessage], + local_storage: LocalStorage, + session_storage: SessionStorage, send: SendCoroutine, recv: RecvCoroutine, ) -> None: @@ -41,11 +51,10 @@ async def serve_layout( try: async with create_task_group() as task_group: task_group.start_soon(_single_outgoing_loop, layout, send) - task_group.start_soon(_single_incoming_loop, task_group, layout, recv) + task_group.start_soon(_single_incoming_loop, task_group, layout, local_storage, session_storage,recv) except Stop: logger.info(f"Stopped serving {layout}") - async def _single_outgoing_loop( layout: LayoutType[LayoutUpdateMessage, LayoutEventMessage], send: SendCoroutine ) -> None: @@ -63,13 +72,27 @@ async def _single_outgoing_loop( logger.error(msg) raise +async def incoming_router( + layout: Layout, + local_storage: LocalStorage, + session_storage: SessionStorage, + event: LayoutEventMessage or LocalStorageEventMessage or SessionStorageEventMessage, +): + if event["type"] == "sync-local-storage": + local_storage._sync(event["storage"]) + elif event["type"] == "sync-session-storage": + session_storage._sync(event["storage"]) + elif event["type"] == "layout-event": + await layout.deliver(event) async def _single_incoming_loop( task_group: TaskGroup, layout: LayoutType[LayoutUpdateMessage, LayoutEventMessage], + local_storage: LocalStorage, + session_storage: SessionStorage, recv: RecvCoroutine, ) -> None: while True: # We need to fire and forget here so that we avoid waiting on the completion # of this event handler before receiving and running the next one. - task_group.start_soon(layout.deliver, await recv()) + task_group.start_soon(incoming_router, layout, local_storage, session_storage, await recv()) diff --git a/src/py/reactpy/reactpy/core/types.py b/src/py/reactpy/reactpy/core/types.py index 45f300f4f..59417017f 100644 --- a/src/py/reactpy/reactpy/core/types.py +++ b/src/py/reactpy/reactpy/core/types.py @@ -1,6 +1,7 @@ from __future__ import annotations -import sys +import sys, json +from starlette.websockets import WebSocket from collections import namedtuple from collections.abc import Mapping, Sequence from types import TracebackType @@ -233,3 +234,89 @@ class LayoutEventMessage(TypedDict): """The ID of the event handler.""" data: Sequence[Any] """A list of event data passed to the event handler.""" + +class LocalStorageEventMessage(TypedDict): + """Message describing an event containing localStorage""" + + type: Literal["sync-local-storage"] + """The type of message""" + storage: dict + """A dictionary containing localStorage items""" + +class SessionStorageEventMessage(TypedDict): + """Message describing an event containing sessionStorage""" + + type: Literal["sync-session-storage"] + """Message describing an event containing localStorage""" + storage: dict + """A dictionary containing localStorage items""" + +class LocalStorage(): + _socket: WebSocket + storage: dict + + def __init__(self, sock): + self._socket = sock + self.storage = {} + + def _sync(self, sto): + self.storage = sto + + async def _sync_client(self): + await self._socket.send_text( + json.dumps( + { + "type": "sync-local-storage", + "storage": self.storage + } + ) + ) + + def get_item( + self, + key: str + ): + return self.storage.get(key) + + async def set_item( + self, + key: str, + value: str + ): + self.storage[key] = value + await self._sync_client() + +class SessionStorage(): + _socket: WebSocket + storage: dict + + def __init__(self, sock): + self._socket = sock + self.storage = {} + + def _sync(self, sto): + self.storage = sto + + async def _sync_client(self): + await self._socket.send_text( + json.dumps( + { + "type": "sync-session-storage", + "storage": self.storage + } + ) + ) + + def get_item( + self, + key: str + ): + return self.storage.get(key) + + async def set_item( + self, + key: str, + value: str + ): + self.storage[key] = value + await self._sync_client() \ No newline at end of file diff --git a/src/py/reactpy/reactpy/samples/use_local_storage.py b/src/py/reactpy/reactpy/samples/use_local_storage.py new file mode 100644 index 000000000..65f63bab3 --- /dev/null +++ b/src/py/reactpy/reactpy/samples/use_local_storage.py @@ -0,0 +1,57 @@ +from reactpy import component, html, run +from reactpy.core.hooks import use_state, use_effect +from reactpy.backend.hooks import use_local_storage + +@component +def App(): + storage = use_local_storage() + key_input, set_key_input = use_state("") + val_input, set_val_input = use_state("") + + @use_effect + def handle_get(): + set_val_input( + storage.get_item( + key_input + ) + ) + + async def handle_set(e): + await storage.set_item( + key_input, + val_input + ) + + return html.div( + html.h1("Local Storage"), + html.input( + { + "type": "text", + "placeholder": "Key", + "value": key_input, + "on_change": lambda e: set_key_input(e["target"]["value"]) + } + ), + html.textarea( + { + "placeholder": "Value", + "value": val_input, + "on_change": lambda e: set_val_input(e["target"]["value"]) + } + ), + html.button( + { + "on_click": handle_get + }, + "Get" + ), + html.button( + { + "on_click": handle_set + }, + "Set" + ) + ) + + +run(App) diff --git a/src/py/reactpy/reactpy/samples/use_session_storage.py b/src/py/reactpy/reactpy/samples/use_session_storage.py new file mode 100644 index 000000000..b1e6f9089 --- /dev/null +++ b/src/py/reactpy/reactpy/samples/use_session_storage.py @@ -0,0 +1,57 @@ +from reactpy import component, html, run +from reactpy.core.hooks import use_state, use_effect +from reactpy.backend.hooks import use_session_storage + +@component +def App(): + storage = use_session_storage() + key_input, set_key_input = use_state("") + val_input, set_val_input = use_state("") + + @use_effect + def handle_get(): + set_val_input( + storage.get_item( + key_input + ) + ) + + async def handle_set(e): + await storage.set_item( + key_input, + val_input + ) + + return html.div( + html.h1("Session Storage"), + html.input( + { + "type": "text", + "placeholder": "Key", + "value": key_input, + "on_change": lambda e: set_key_input(e["target"]["value"]) + } + ), + html.textarea( + { + "placeholder": "Value", + "value": val_input, + "on_change": lambda e: set_val_input(e["target"]["value"]) + } + ), + html.button( + { + "on_click": handle_get + }, + "Get" + ), + html.button( + { + "on_click": handle_set + }, + "Set" + ) + ) + + +run(App)