Feat/suspense module mounter - #532
Conversation
- fetchResources adopts server-emitted module styles (data-module-ssr-href) instead of removing and re-fetching them, avoiding FOUC - add createEmbeddedModuleFetcher: reads resources from embedded payload - useModuleMounter/createLazyMounter call module.update() on runParams change when available, falling back to remount otherwise - export MODULE_SSR_HREF_ATTRIBUTE
Isomorphic component exported from @alfalab/scripts-modules/ssr. - server: suspends on getModuleResources with ssr flag, inlines module styles, renders module html into an outlet, embeds resources payload - client: reads embedded payload (no duplicate resources request), hydrates server markup via module.hydrate (or mounts when absent), calls module.update on runParams change, cleans up on unmount - client loader is built internally wrapping the fetcher in createEmbeddedModuleFetcher, so the resources endpoint is not called twice - add exports map (root '.' unchanged, new './ssr' subpath) - export MODULE_SSR_ROOT_ATTRIBUTE and MODULE_SSR_MOUNT_ID_ATTRIBUTE - round-trip test: renderToPipeableStream -> serialize -> jsdom hydrate
Document in the ssr spec why createSsrMounter shipped without the fallback prop (server/client hydration-consistency problem), the process-global suspense cache trade-offs (cross-request sharing, no abort wiring), and the unused link-mode style adoption path. Also note that the factory builds its client loader internally, superseding the §5.3 passed-in-loader signature.
🦋 Changeset detectedLatest commit: 331ff34 The changes in this PR will be included in the next version bump. This PR includes changesets to release 3 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
Сложно полноценно в голове скомпилировать работу и что-то дельное найти, выглядит классно |
| const previouslyAddedStyles = Array.from( | ||
| cssTargetNode.querySelectorAll(`${styleTag}[${DATA_APP_ID_ATTRIBUTE}="${moduleId}"]`), | ||
| ); | ||
| ).filter((style) => !adoptedStyleTags.includes(style)); |
There was a problem hiding this comment.
а после добавление логики с filter разве подходит название для перемнной previouslyAddedStyles?)
| const moduleVendorFiles = manifest[`vendor-${moduleId}`] || {}; | ||
|
|
||
| // js/css в манифесте могут быть строкой или массивом, нормализуем в плоский список. | ||
| const toArray = (value: string | string[] | undefined): string[] => { |
There was a problem hiding this comment.
это вроде бы можно вынести за createModuleFetcher
чтобы каждый не создавался toArray при вызове createModuleFetcher
|
|
||
| link.addEventListener('load', finish); | ||
| link.addEventListener('error', finish); | ||
| abortSignal?.addEventListener('abort', finish); |
There was a problem hiding this comment.
может не увидел, но кажется нигде не очищаются listener при размонтировании модуля
(остается в памяти)
| const hadServerHtmlRef = useRef(false); | ||
|
|
||
| if (snapshotRef.current === null) { | ||
| const { snapshot, hadServerHtml } = readServerMarkup(instanceId); |
There was a problem hiding this comment.
а это окей что вызывается readServerMarkup во время ренндера?
как будто это нужно вызывать в useEffect
useEffect(() => {.....}, [instanceId])
| `ssrRunParams` должны быть JSON-сериализуемыми. В них передаётся только то, от чего зависит | ||
| серверная разметка модуля. Клиентские значения вроде callback, ref или DOM-объектов передаются | ||
| только в `runParams` и доступны модулю на этапе `hydrate`/`mount`/`update`. Если один и тот же | ||
| модуль рендерится на странице несколько раз, передавайте стабильный `instanceId`. |
There was a problem hiding this comment.
предлагаю давать warning в консоль для таких случаев, когда ssrRunParams одинаковые есть
| }); | ||
|
|
||
| if (!response.ok) { | ||
| throw new Error(response.statusText); |
There was a problem hiding this comment.
может тут еще возвращать и тело ответа? там бывает полезная инфа для клиента
| // поэтому между запросами данные не переиспользуются (важно: moduleState может зависеть | ||
| // от запроса). Все чтения в рамках одного синхронного прохода рендера происходят до | ||
| // отложенного удаления. | ||
| const cache = new Map<string, CacheEntry<unknown>>(); |
There was a problem hiding this comment.
ты хоть в описании ПРа написал, что тут осталась недоработки с шаренным кэшом
то что два пользователя гипотитечски могут увидеть не свои данные
может хотя бы добавить айдишник request для ключа в cache?
const key =${requestId}_${key}``
Добавлена возможность монтировать модули через суспенс. Как на клиенте, так и на сервере.
Для серверного монтирования есть дополнительные ограничения, поэтому он реализован отдельным методом.
По сути это все добавление новой фичи и никак не ломает предыдущее использование.
Общая логика описана в
docs/specs/ssr-spec.md. Собственно по этой спеке все и делалось.Единственная реальная "недаработка" в спеке - шареный кеш для суспенса, который чисто теоретически может некорректно пошарится между разными запросами если модули из разных запросов используют абсолютно одинаковый набор параметров для модулей. Буду лечить, но отдельным пр-ом. По сути риск подобного сценария минимален.
В целом тут больше текста и тестов, чем самого реального кода.
Что в реальности реализовано:
В
script-modules:createSsrMounter- изоморфная фабрика компонента для запихивания его в suspense. Оно уже умеет рендрится на сервере и затем корректно гидрируется на клиенте без перезапроса getModuleResources. Этот вариант НЕ умеет работать с shadowDOM.Остальные изменения в пакете по сути позволяют всему этому работать.
В
scripts-server:В
scripts:Как вообще работает подключение модулей с SSR:
На сервере хоста
createSsrMounterрендеритServerModuleвнутри<Suspense>. React приостанавливает рендер, пока не разрешится Suspense-ресурс.Suspense-ресурс вызывает
loadServerModule, которая делаетgetModuleResources({ ssr: { runParams } })— запрос к серверу модуля с флагом SSR.Сервер модуля в
createGetModulesMethodвидит флагssr, вызываетmodule.renderToHtml(moduleState, ssrRunParams), возвращает{ html, scripts, styles, moduleState }.Компонент рендерит:
<style>(default) или<link>приstylesMode: 'link'; атрибутыdata-module-ssr-hrefиdata-parent-app-idдля того чтобы на клиенте рантайм не началл грузить их еще раз;<div>cdata-module-ssr-mount-id;<script type="application/json">сdata-module-ssr-payload.Стили default (module federation) модулей добавляет
AttributeModuleCssPluginпри сборке: обходит граф чанков и записывает css в запись модуля в манифесте. При SSR-запросеcreateGetModulesMethodотдаёт эти стили; для обычных запросов ответ остаётся прежним (css: []).На клиенте
ClientModuleпри первом рендере захватывает snapshot серверной разметки (она уже в DOM) и рендерит её какdangerouslySetInnerHTML— React не трогает стили и payload.Запускает лоадер, обёрнутый в
createEmbeddedModuleFetcher: ищет<script type="application/json">сdata-module-ssr-payload, к сети не обращается. Если payload не найден — падает на fallback-фетчер (исходныйgetModuleResources).Загружает скрипты модуля — через MF runtime (default) или
<script>-теги (compat). Стили сdata-module-ssr-hrefнаследуются и не загружаются повторно.Гидрация: если у модуля есть
hydrate— вызывает его, React гидрирует существующую разметку. Если нет — очищаетinnerHTMLмонтируемого узла и вызываетmount(graceful degradation, warning в dev).Обновление: при изменении
runParamsвызываетmodule.update(target, params, state)— без перемонтирования.Prerelease versions:
@alfalab/scripts-modules@0.0.0-next-20260709074302@alfalab/scripts-server@0.0.0-next-20260709074302arui-scripts@0.0.0-next-20260709074302