feat(Storybook): in-page, state-preserving hot reloading

In comparison to generic page-wise hot reloading, this technique
requires using dedicated component within a storybook page but
provides greater flexibility e.g. preserving component state across
source reloads.

Closes: #7975
This commit is contained in:
Michał Cieślak 2022-10-19 21:51:39 +02:00 committed by Michał
parent 8981b8615a
commit dfc5db27d5
12 changed files with 377 additions and 28 deletions

View File

@ -1,7 +1,5 @@
#include <QGuiApplication>
#include <QQmlApplicationEngine>
#include <QQmlContext>
#include <QQmlEngine>
#include <cachecleaner.h>
#include <directorieswatcher.h>
@ -18,7 +16,7 @@ int main(int argc, char *argv[])
QQmlApplicationEngine engine;
QStringList additionalImportPaths {
const QStringList additionalImportPaths {
SRC_DIR + QStringLiteral("/../ui/StatusQ/src"),
SRC_DIR + QStringLiteral("/../ui/app"),
SRC_DIR + QStringLiteral("/../ui/imports"),

View File

@ -19,10 +19,11 @@ ApplicationWindow {
font.pixelSize: 13
HotReloader {
loader: viewLoader
enabled: hotReloadingCheckBox.checked
id: reloader
onReloaded: reloadingAnimation.restart()
loader: viewLoader
enabled: hotReloaderControls.enabled
onReloaded: hotReloaderControls.notifyReload()
}
ListModel {
@ -31,6 +32,9 @@ ApplicationWindow {
ListElement {
title: "CommunitiesPortalLayout"
}
ListElement {
title: "StatusCommunityCard"
}
ListElement {
title: "LoginView"
}
@ -70,29 +74,12 @@ ApplicationWindow {
}
}
CheckBox {
id: hotReloadingCheckBox
HotReloaderControls {
id: hotReloaderControls
Layout.fillWidth: true
text: "Hot reloading"
Rectangle {
anchors.fill: parent
border.color: "red"
border.width: 2
opacity: 0
OpacityAnimator on opacity {
id: reloadingAnimation
running: false
from: 1
to: 0
duration: 500
easing.type: Easing.InQuad
}
}
onForceReloadClicked: reloader.forceReload()
}
Pane {
@ -134,6 +121,12 @@ ApplicationWindow {
anchors.centerIn: parent
visible: viewLoader.status === Loader.Loading
}
Label {
anchors.centerIn: parent
visible: viewLoader.status === Loader.Error
text: "Loading page failed"
}
}
}
@ -141,6 +134,6 @@ ApplicationWindow {
property alias currentPage: root.currentPage
property alias loadAsynchronously: loadAsyncCheckBox.checked
property alias darkMode: darkModeCheckBox.checked
property alias hotReloading: hotReloadingCheckBox.checked
property alias hotReloading: hotReloaderControls.enabled
}
}

View File

@ -51,7 +51,7 @@ ListView {
Label {
Layout.fillWidth: true
text: "community id: " + model.communityId
text: "community id: " + model.id
font.weight: Font.Bold
}

View File

@ -0,0 +1,91 @@
import QtQuick 2.14
import QtQuick.Controls 2.14
import QtQuick.Layouts 1.14
import Storybook 1.0
SplitView {
id: root
orientation: Qt.Vertical
readonly property string source: `
import QtQml 2.14
import StatusQ.Components 0.1
Component {
StatusCommunityCard {
name: nameTextField.text
}
}
`
Logs { id: logs }
Item {
SplitView.fillWidth: true
SplitView.fillHeight: true
HotLoader {
id: loader
anchors.centerIn: parent
source: sourceCodeBox.sourceCode
Connections {
target: loader.item
function onClicked() {
logs.logEvent("StatusCommunityCard::clicked",
["communityId"], arguments)
}
}
}
Pane {
anchors.fill: parent
visible: !!loader.errors
CompilationErrorsBox {
anchors.fill: parent
errors: loader.errors
}
}
}
LogsAndControlsPanel {
id: logsAndControlsPanel
SplitView.minimumHeight: 100
SplitView.preferredHeight: 200
logsView.logText: logs.logText
RowLayout {
anchors.fill: parent
Item {
Layout.fillWidth: true
Layout.fillHeight: true
ColumnLayout {
anchors.fill: parent
TextField {
id: nameTextField
text: "Card name!"
}
}
}
SourceCodeBox {
id: sourceCodeBox
Layout.preferredWidth: root.width / 2
Layout.fillHeight: true
sourceCode: root.source
hasErrors: !!loader.errors
}
}
}
}

View File

@ -0,0 +1,27 @@
import QtQuick 2.14
import QtQuick.Controls 2.14
GroupBox {
title: "compilation errors"
property var errors // QQmlError
ScrollView {
id: scrollView
anchors.fill: parent
visible: !!loader.errors
clip: true
TextEdit {
id: errorsTextEdit
width: scrollView.width
font.family: "courier"
selectByMouse: true
readOnly: true
text: !!errors ? JSON.stringify(errors.qmlErrors, null, 2) : ""
}
}
}

View File

@ -0,0 +1,53 @@
import QtQuick 2.14
import Storybook 1.0
QtObject {
id: root
property string source
property bool rethrowErrors: true
readonly property alias component: d.component
readonly property alias errors: d.errors // QQmlError
onSourceChanged: d.createComponent()
readonly property Connections _d: Connections {
id: d
target: SourceWatcher
property Component component
property var errors: null
function createComponent() {
if (component) {
component.destroy()
component = null
}
try {
component = Qt.createQmlObject(root.source,
this,
"HotComponentFromSource_dynamicSnippet"
)
d.errors = null
} catch (e) {
d.errors = e
if (root.rethrowErrors)
throw e
}
}
function onChanged() {
CacheCleaner.clearComponentCache()
createComponent()
}
}
Component.onCompleted: {
if (root.source)
d.createComponent()
}
}

View File

@ -0,0 +1,13 @@
import QtQuick 2.14
Loader {
sourceComponent: hotComponent.component
property alias source: hotComponent.source
property alias rethrowErrors: hotComponent.rethrowErrors
readonly property alias errors: hotComponent.errors
HotComponentFromSource {
id: hotComponent
}
}

View File

@ -0,0 +1,55 @@
import QtQml 2.14
import QtQuick 2.14
import Storybook 1.0
QtObject {
id: root
/*required*/ property Loader loader
property bool enabled: false
signal reloaded
function forceReload() {
// clearing component cache right after removing
// source from async loader causes undefined behavior
// and app crashes on Qt 5.14.2. For that reason
// asynchronous is set to false first and restored
// to original value after clearing.
d.asyncBlocker.when = true
d.sourceBlocker.when = true
CacheCleaner.clearComponentCache()
d.asyncBlocker.when = false
d.sourceBlocker.when = false
reloaded()
}
readonly property Connections _d: Connections {
id: d
target: SourceWatcher
readonly property Binding asyncBlocker: Binding {
target: root.loader
property: "asynchronous"
value: false
when: false
}
readonly property Binding sourceBlocker: Binding {
target: root.loader
property: "source"
value: ""
when: false
}
function onChanged() {
if (!root.enabled)
return
forceReload()
}
}
}

View File

@ -0,0 +1,58 @@
import QtQuick 2.14
import QtQuick.Controls 2.14
import QtQuick.Layouts 1.14
Item {
id: root
implicitHeight: row.implicitHeight
property alias enabled: hotReloadingCheckBox.checked
signal forceReloadClicked
function notifyReload() {
reloadingAnimation.restart()
}
RowLayout {
id: row
anchors.left: parent.left
anchors.right: parent.right
CheckBox {
id: hotReloadingCheckBox
Layout.fillWidth: true
text: "Hot reloading"
}
Button {
Layout.rightMargin: 5
text: "Reload now"
onClicked: root.forceReloadClicked()
}
}
Rectangle {
anchors.fill: parent
border.color: "red"
border.width: 2
color: "transparent"
opacity: 0
OpacityAnimator on opacity {
id: reloadingAnimation
running: false
from: 1
to: 0
duration: 500
easing.type: Easing.InQuad
}
}
}

View File

@ -0,0 +1,37 @@
import QtQuick 2.14
import QtQuick.Controls 2.14
GroupBox {
id: root
title: "source code"
property string sourceCode
property bool hasErrors: false
ScrollView {
id: scrollView
anchors.fill: parent
clip: true
implicitHeight: 0
implicitWidth: 0
contentHeight: sourceTextEdit.implicitHeight
contentWidth: scrollView.width
TextEdit {
id: sourceTextEdit
width: scrollView.width
font.family: "courier"
text: StorybookUtils.formatQmlCode(root.sourceCode)
color: root.hasErrors ? "darkred" : "black"
selectByMouse: true
wrapMode: Text.Wrap
onTextChanged: root.sourceCode = text
}
}
}

View File

@ -21,4 +21,23 @@ QtObject {
const onlyUnique = (value, index, self) => self.indexOf(value) === index
return values.filter(onlyUnique)
}
function formatQmlCode(code) {
code = code.replace(/^\n+/, "")
code = code.replace(/\s+$/, "")
const match = code.match(/^[ \t]*(?=\S)/gm)
if (!match)
return code
const minIndent = match.reduce((r, a) => Math.min(r, a.length),
Number.POSITIVE_INFINITY)
if (minIndent === 0)
return code
const regex = new RegExp(`^[ \\t]{${minIndent}}`, "gm")
return code.replace(regex, "")
}
}

View File

@ -1,7 +1,12 @@
CompilationErrorsBox 1.0 CompilationErrorsBox.qml
HotComponentFromSource 1.0 HotComponentFromSource.qml
HotLoader 1.0 HotLoader.qml
HotReloader 1.0 HotReloader.qml
HotReloaderControls 1.0 HotReloaderControls.qml
ImageSelectPopup 1.0 ImageSelectPopup.qml
Logs 1.0 Logs.qml
LogsAndControlsPanel 1.0 LogsAndControlsPanel.qml
LogsView 1.0 LogsView.qml
PagesList 1.0 PagesList.qml
SourceCodeBox 1.0 SourceCodeBox.qml
singleton StorybookUtils 1.0 StorybookUtils.qml